NewLife/NewLife.Remoting

自动更新机制增强:冒烟测试、回滚与兼容性提升

本次提交全面升级自动更新机制,支持冒烟测试+自动回滚,完善 Partial/Standard/Full 多模式文件覆盖,增强 muxer 启动与诊断,适配慢速存储,新增多项单元测试与详细文档,极大提升更新安全性与平台兼容性。
大石头 authored at 2026-06-08 21:20:57
464080b
Tree
1 Parent(s) 1e87a31
Summary: 4 changed files with 950 additions and 78 deletions.
Added +306 -0
Modified +33 -9
Modified +359 -68
Modified +252 -1
Added +306 -0
diff --git "a/Doc/\350\207\252\345\212\250\346\233\264\346\226\260\346\234\272\345\210\266.md" "b/Doc/\350\207\252\345\212\250\346\233\264\346\226\260\346\234\272\345\210\266.md"
new file mode 100644
index 0000000..57a6254
--- /dev/null
+++ "b/Doc/\350\207\252\345\212\250\346\233\264\346\226\260\346\234\272\345\210\266.md"
@@ -0,0 +1,306 @@
+# 自动更新机制
+
+## 概述
+
+`NewLife.Remoting` 提供了一套完整的应用自动更新机制,涵盖**检测 → 下载 → 校验 → 解压 → 文件覆盖 → 进程重启**全链路。核心类为 `Upgrade`(文件操作)和 `ClientBase.Upgrade`/`Restart`(流程编排),已在星尘代理(StarAgent)等生产环境中稳定运行超过 20 年。
+
+### 解决的问题
+
+- **文件覆盖**:运行中的 exe/dll 在 Windows 上被锁定,Linux 上覆盖可能导致段错误。通过「先改名再拷贝」策略安全替换。
+- **进程重启**:跨平台(Windows/Linux/OSX)拉起新版本进程,支持服务模式(systemd)和普通进程模式。
+- **版本管理**:避免频繁更新同一个版本(`_lastVersion` 去重)。
+- **回滚保护**:更新失败时自动恢复被移动的旧文件。
+
+---
+
+## 更新流程
+
+```mermaid
+sequenceDiagram
+    participant S as 服务端
+    participant C as ClientBase
+    participant U as Upgrade
+    participant F as 文件系统
+    participant P as 新进程
+
+    C->>S: 查询更新 (UpgradeAsync)
+    S-->>C: 返回更新信息 (版本/下载地址/哈希/通道)
+    C->>U: Download(url)
+    U->>F: HTTP 下载 .zip 到 Update/ 目录
+    U-->>C: 下载完成 (MD5)
+    C->>U: CheckFileHash(hash)
+    alt 哈希不符
+        C-->>C: 上报 "哈希校验失败",终止
+    else 哈希一致
+        C->>U: Extract()
+        U->>F: 解压到临时目录
+        C->>U: Update()
+        U->>F: 旧 .dll/.exe → .del 改名
+        U->>F: CopyAndReplace(临时 → 目标)
+        U->>F: Linux: sync 强制落盘
+        C->>C: Restart(upgrade)
+        C->>U: Run(name, args)
+        U->>P: dotnet name.dll args
+        P-->>U: 进程持续运行 → 成功
+    end
+```
+
+### 各阶段详解
+
+| 阶段 | 方法 | 说明 |
+|------|------|------|
+| 检测 | `UpgradeAsync` | 调用服务端接口查询是否有新版本,返回 `IUpgradeInfo`(版本号、下载地址、哈希、通道、强制更新标志) |
+| 下载 | `Upgrade.Download` | HTTP GET 下载 .zip 更新包到 `Update/` 目录,超时 `DownloadTimeoutSeconds`(默认 30s)。通过 Content-Disposition 头获取真实文件名。瞬时网络故障自动重试两次(共三次尝试) |
+| 校验 | `Upgrade.CheckFileHash` | 对下载文件计算 MD5,与服务端下发的哈希比对,防止文件损坏或被篡改 |
+| 解压 | `Upgrade.Extract` | 解压 .zip 到系统临时目录,仅支持 .zip 格式 |
+| 预安装 | `Info.Preinstall` | 可选。在覆盖文件之前执行预安装脚本(如安装运行时依赖) |
+| 覆盖 | `Upgrade.Update` | 按 `Mode`(Partial/Standard/Full)清理目标目录;Standard 模式将当前 .exe/.dll 改名为 .del(Linux 上无法覆盖运行中文件);`CopyAndReplace` 拷贝新文件;Linux 上 `sync` 强制落盘 + `StorageFlushDelay` 等待;失败时恢复旧文件 |
+| 执行器 | `Info.Executor` | 可选。覆盖完成后执行更新后脚本 |
+| 重启 | `Restart` | 调用 `Upgrade.Run` 拉起新版本进程。通过 `BuildRestartArguments()` 虚方法构建重启参数,`MyStarClient` 可重载以支持服务模式(`-restart`)和进程模式(`-run`) |
+
+---
+
+## 进程拉起策略
+
+### Muxer 优先
+
+`Upgrade.Run` 采用 **muxer 优先** 策略启动 .NET Core 应用:
+
+```mermaid
+flowchart TD
+    A["Run(name, args)"] --> B{"存在 name.runtimeconfig.json ?"}
+    B -->|"是 .NET Core"| C["dotnet name.dll args"]
+    B -->|"否"| D{"存在 name.exe ?"}
+    D -->|"是 .NET Framework"| E["name.exe args"]
+    D -->|"否"| F["抛异常: 无法确定启动方式"]
+    
+    C --> G["解析 dotnet 绝对路径"]
+    G --> H["注入 DOTNET_ROOT 环境变量"]
+    H --> J["Process.Start (RedirectStandardError=true)"]
+    J --> K{"WaitForExit(msWait)"}
+    K -->|"仍在运行"| L["✅ 启动成功"]
+    K -->|"已退出, ExitCode=0"| L
+    K -->|"已退出, ExitCode≠0"| M["❌ 捕获 stderr → LastErrorMessage"]
+```
+
+### 为什么用 muxer 而不是 apphost
+
+- `dotnet <dll>` 是跨平台标准写法,与 StarAgent 的系统约定一致。
+- .NET muxer(`dotnet`)能根据 `.runtimeconfig.json` 自动路由到正确的运行时版本,支持多版本共存。
+- 原生 apphost(无扩展名的二进制)依赖运行时可发现性(`DOTNET_ROOT` / `/etc/dotnet/install_location`),在 systemd 精简环境下可能失败。
+- muxer 本身已在内存中(热路径),即使目标 dll 尚未完全落盘,报错信息也比 apphost 的 SIGBUS 崩溃更具可读性。
+
+### dotnet 路径解析
+
+`ResolveDotNetPath()` 按优先级查找:
+
+1. `DOTNET_ROOT` 环境变量 → `$DOTNET_ROOT/dotnet`
+2. `/usr/bin/dotnet`(Linux 标准符号链接位置)
+3. `/usr/local/bin/dotnet`
+4. 回退到 `"dotnet"`(依赖系统 PATH)
+
+### 环境变量注入
+
+`InjectDotNetRoot()` 从 dotnet 路径反推运行时根目录,向子进程注入:
+- `DOTNET_ROOT` — 通用运行时根目录
+- `DOTNET_ROOT_ARM64` / `DOTNET_ROOT_X64` — 架构特定的高优先级变量
+
+### 启动诊断
+
+当进程异常退出(非零退出码)时,`Run` 会:
+1. 读取子进程 stderr(最多 4096 字节)
+2. 将 `ExitCode` + stderr 写入 `LastErrorMessage`
+3. 调用方(`ClientBase.Restart` / `MyStarClient.Restart`)通过 `WriteInfoEvent` 上报到星尘节点历史
+
+---
+
+## 更新模式(UpdateModes)
+
+`Upgrade.Mode` 控制更新时目标目录的清理策略:
+
+| 模式 | 值 | 说明 |
+|------|-----|------|
+| `Partial` | 1 | **部分包**:不清理目标目录,直接覆盖包内文件,保留其它文件 |
+| `Standard` | 2 | **标准包**(默认):覆盖前将 `*.exe;*.dll` 移名为 `.del`,然后 `CopyAndReplace` 覆盖 |
+| `Full` | 3 | **完整包**:清空目标目录所有文件(移名为 `.del`)和子目录。保护清单:`appsettings.json`、`appsettings.*.json`、`Update/`、`Log/`、`Config/` 及其下的 `.del` 文件 |
+
+`Mode` 默认值为 `Standard`,如需由服务端下发可扩展 `IUpgradeInfo` 增加可选字段。
+
+### 进程终止
+
+`Upgrade.KillSelf()` 用于更新完成后优雅终止当前进程:
+- 非控制台应用先调用 `CloseMainWindow()`
+- 最后执行 `Environment.Exit(0)`
+
+> 注意:`Environment.Exit` 之后的代码不会执行,这是预期行为。
+
+---
+
+## 冒烟测试与自动回滚
+
+自更新最怕新版本有 bug 把自己"玩死"。本机制采用 **冒烟测试 + 自动回滚** 策略确保不死:
+
+```mermaid
+flowchart TD
+    A["Update() 完成<br/>新版文件覆盖到位<br/>旧版 .exe/.dll/.json 备份为 .del"] --> B["Run(name, args, 3000ms)"]
+    B --> C{"新进程在 3s 内退出?"}
+    C -->|"仍在运行(冒烟通过)"| D["KillSelf<br/>旧进程退出,新进程接管"]
+    C -->|"异常退出"| E["等待 5s(落盘延迟 / JIT 预热)"]
+    E --> F["Run(name, args, 3000ms) 重试"]
+    F --> G{"重试结果?"}
+    G -->|"通过"| D
+    G -->|"仍失败"| H["Rollback()<br/>恢复所有 .del → 旧版文件"]
+    H --> I["记录失败原因 + stderr<br/>旧进程继续提供服务"]
+    I --> J{"设备重启?"}
+    J -->|"是"| K["systemd 拉起旧版(已验证)✅"]
+    J -->|"否"| L["旧进程持续运行 ✅"]
+```
+
+### 核心逻辑
+
+1. **冒烟测试**:旧进程不退出,先尝试拉起新进程。`Run` 在 3 秒窗口内观察新进程是否存活。
+2. **首次失败不立即判定**:等待 5 秒后重试一次,给慢速存储(eMMC GC)、JIT 冷启动等留下缓冲。
+   > 同一启动周期内 `exec()` 从内核页缓存读取文件——数据是完整的,不存在"读到半写入"问题。重试的价值在于排除 eMMC 后台垃圾回收、CPU 降频冷启动等瞬时延迟,而非页缓存一致性问题。
+3. **重试仍失败** → `Rollback` 将所有 `.del` 备份恢复(含 `.runtimeconfig.json`、`.deps.json`),旧进程继续运行。
+4. **测试通过** → `KillSelf` 退出旧进程,新进程接管。即使此时 systemd 重启服务,拉起的也是已验证的新版。
+
+### 备份范围
+
+`Standard` 模式在 `CopyAndReplace` 前备份:
+- `*.exe` / `*.dll` — 可执行文件(覆盖运行中文件会导致段错误,必须先改名)
+- `*.runtimeconfig.json` / `*.deps.json` — 随 .NET 版本变化的构建输出文件
+- 配置文件 `appsettings.json` 不会被覆盖(`CopyAndReplace` 内部保护),因此不备份
+
+### 与 StarAgent 的配合
+
+StarAgent 使用 `dotnet StarAgent.dll` 启动新版作为冒烟测试:
+
+- 若新版正常 → 测试通过 → 旧版退出 → systemd 管理新版
+- 若新版有致命缺陷(启动即崩)→ 测试失败 → `Rollback` 恢复旧版 → 旧版继续运行
+- 若设备断电重启 → systemd 拉起的是恢复后的旧版,不会"自毁"
+
+`Upgrade.Rollback()` 自动处理两种 `.del` 命名:
+- 普通:`StarAgent.dll.del` → 恢复为 `StarAgent.dll`
+- 时间戳变体:`StarAgent.dll_260608153000.del` → 恢复为 `StarAgent.dll`
+
+---
+
+## A4 ARM64 故障案例复盘
+
+### 现象
+
+A4 设备(ARM64 + Ubuntu 22.04 + eMMC 存储)在 net9 → net10 自动更新后,`Upgrade.Run` 拉起新进程失败。节点历史显示:
+
+```
+13:35:30 强制更新完成,准备重启后台服务!PID=1316
+13:35:31 拉起新进程失败,延迟3000ms后重试
+13:35:34 强制更新完成,但拉起新进程失败!
+```
+
+同一版本在其他 x86/ARM 机器上正常。
+
+### 时间线
+
+| 时间 | 事件 |
+|------|------|
+| 13:34:35 | 旧 agent(3.1.2024.1117)→ net9 agent 升级成功 |
+| 13:34:54 | 开始下载 net10 运行时 |
+| 13:35:28 | **net10 运行时安装完成**(解压到 `/usr/share/dotnet`,创建软链 `/usr/bin/dotnet`) |
+| 13:35:29 | net9 agent 检测到 net10 更新,开始下载 |
+| 13:35:30 | 覆盖完成,调用 `Restart` |
+| 13:35:31 | **拉起失败**(进程 ~1 秒内非零退出) |
+| 13:35:34 | 重试仍失败,上报 "拉起新进程失败" |
+| 13:36:00 | 旧进程退出,systemd 自动重启服务 → 新 agent 正常上线 |
+
+### 根因
+
+**不是代码逻辑 bug,是 A4 ARM64 慢速存储(eMMC)上的 IO 竞态条件。** 此故障在 2024 年 11 月已被首次记录(commit `0eb5f38`:"在A4上执行StarAgent自动更新时,有一定几率无法自动重启")。
+
+```mermaid
+flowchart LR
+    A["CopyAndReplace<br/>拷贝新二进制到磁盘"] --> B["数据写入页缓存<br/>(未提交到 NAND)"]
+    B --> C["exec() 新进程"]
+    C --> D{"页缓存已回写?"}
+    D -->|"是(概率性)"| E["✅ 读到完整二进制"]
+    D -->|"否(概率性)"| F["❌ 读到不完整二进制<br/>SIGBUS / 异常退出"]
+```
+
+**加重因素**:net10 运行时安装与升级仅间隔 2 秒,系统 IO 高负载时页缓存回写延迟更大,触发几率显著增加。
+
+### 修复措施(三层防御)
+
+| 层 | 措施 | 文件 | 作用 |
+|---|------|------|------|
+| ① | `CopyAndReplace` 后 `sync` 强制落盘 | `Upgrade.Update` | 确保二进制数据提交到存储介质后再重启 |
+| ② | ARM64 Linux 上 `StorageFlushDelay`(默认 800ms)等待页缓存回写 | `Upgrade.Update` | 给慢速存储(eMMC/SD)额外回写时间窗口,可配置 |
+| ③ | stderr 捕获 → `LastErrorMessage` → `WriteInfoEvent` 上报 | `Upgrade.Run` / `ClientBase.Restart` | 即使失败也能在节点历史看到 ExitCode 和错误输出 |
+
+### 为什么 2024-11 的补丁(加重试)不够
+
+重试时 IO 可能仍然拥堵(系统正在处理大量回写),导致第二次 `exec()` 依然读到不完整二进制。**重试只降低失败概率,不解决根因(数据未落盘)。**
+
+---
+
+## 兼容性
+
+### 目标框架
+
+`Upgrade.cs` 编译面向 `net45;net461;netstandard2.0;netstandard2.1;net5.0-net10.0`。
+
+- **net45/net461**:`InjectDotNetRoot` 跳过架构特定环境变量(`RuntimeInformation` 不可用)。
+- **.NET Core 5.0+**:完整支持 `DOTNET_ROOT` + 架构特定变量。
+
+### 平台差异
+
+| 平台 | 启动方式 | 文件改名 |
+|------|---------|---------|
+| Windows | 直接执行 .exe 或 `dotnet &lt;name&gt;.dll` | .exe/.dll → .del(覆盖前改名) |
+| Linux | `dotnet &lt;name&gt;.dll`(muxer 优先),apphost 仅用作非 .NET Core 兜底 | .exe/.dll → .del + `sync` 强制落盘 + `chmod +x` |
+| OSX | `dotnet &lt;name&gt;.dll` | 同 Linux 逻辑 |
+
+---
+
+## Stardust MyStarClient 适配说明
+
+`MyStarClient` 在 `D:\X\Stardust\StarAgent\MyStarClient.cs` 中重写了 `Restart` 以支持服务/进程双模式,并已同步基类的冒烟测试 + 自动回滚策略。
+
+### 双模式差异
+
+| 模式 | 启动参数 | 成功时 | 失败时 |
+|------|---------|--------|--------|
+| **服务模式**(systemd) | `-restart -upgrade` | 不自杀,外部命令重启服务会结束当前进程 | `Rollback` 恢复旧版,记录 `LastErrorMessage` |
+| **进程模式**(普通) | `-run -upgrade` | `Service.StopWork` + `KillSelf` | `Rollback` 恢复旧版,记录 `LastErrorMessage` |
+
+### 与基类的对齐
+
+`MyStarClient.Restart` 与 `ClientBase.Restart` 使用完全相同的三级策略:
+
+1. **Run(name, args, 3000ms)** — 冒烟测试
+2. **失败 → 等待 5s → 重试一次**(应对慢速存储 / JIT 预热)
+3. **仍失败 → `upgrade.Rollback()`** — 恢复所有 `.del` 恢复旧版文件
+4. 失败日志均携带 `upgrade.LastErrorMessage`(含 ExitCode + stderr)
+
+### 为什么服务模式不调用 KillSelf
+
+服务模式下,`-restart -upgrade` 参数告诉新版 StarAgent 通过 `systemctl restart` 接管服务。systemd 会先杀旧进程再启新进程,所以旧进程无需(也不应)主动退出——否则可能与 systemd 的进程管理产生竞态。
+
+### 后续优化方向
+
+当前双模式仍需整体覆写 `Restart`,因为服务模式的"不自杀"语义无法通过纯 `BuildRestartArguments()` 钩子表达。如果基类增加 `protected virtual Boolean ShouldKillSelf => true` 属性,`MyStarClient` 可仅覆写 `BuildRestartArguments` + `ShouldKillSelf`,从而消除整段 `Restart` 覆写。
+
+---
+
+## 相关文件
+
+| 文件 | 说明 |
+|------|------|
+| `NewLife.Remoting/Clients/Upgrade.cs` | 文件下载、解压、覆盖、进程拉起 |
+| `NewLife.Remoting/Clients/ClientBase.cs` | 更新流程编排(`Upgrade`、`Restart`) |
+| `D:\X\Stardust\StarAgent\MyStarClient.cs` | StarAgent 重写的重启逻辑(服务/进程两路) |
+| `D:\X\NewLife.Agent\NewLife.Agent\Models\SystemdSetting.cs` | systemd 服务单元生成 |
+| `D:\X\Stardust\Stardust\Managers\NetRuntime.cs` | .NET 运行时安装 |
+
+## 相关文档
+
+- [ClientBase 使用说明](ClientBase.md)
+- [重试策略](重试策略.md)
Modified +33 -9
diff --git a/NewLife.Remoting/Clients/ClientBase.cs b/NewLife.Remoting/Clients/ClientBase.cs
index fc3515e..ee5d7c9 100644
--- a/NewLife.Remoting/Clients/ClientBase.cs
+++ b/NewLife.Remoting/Clients/ClientBase.cs
@@ -979,6 +979,15 @@ public abstract class ClientBase : DisposeBase, IApiClient, ICommandClient, IEve
     }
 
     /// <summary>更新完成,重启自己</summary>
+    /// <remarks>
+    /// 自更新策略(冒烟测试 + 自动回滚):
+    /// 1. 新版文件已覆盖到位,旧版 .exe/.dll/.json 备份为 .del
+    /// 2. 旧进程尝试拉起新进程作为冒烟测试——若新版本在 3 秒内异常退出,说明存在致命缺陷
+    /// 3. 首次失败不立即判定为缺陷——等待 5 秒(给慢速存储落盘 + 运行时 JIT 预热等留出时间),重试一次
+    /// 4. 重试仍失败 → 回滚所有 .del 恢复旧版文件 → 旧进程继续提供服务
+    ///    (同一启动周期内 exec 从页缓存读取,落盘延迟不是 root cause;重试主要应对 eMMC GC、JIT 冷启动等)
+    /// 5. 两次均通过 → 旧进程退出,新进程接管
+    /// </remarks>
     /// <param name="upgrade"></param>
     protected virtual void Restart(Upgrade upgrade)
     {
@@ -989,23 +998,22 @@ public abstract class ClientBase : DisposeBase, IApiClient, ICommandClient, IEve
         if (name.IsNullOrEmpty()) return;
 
         // 重新拉起进程。对于大多数应用,都是拉起新进程,然后退出当前进程;对于星尘代理,通过新进程来重启服务。
-        var args = Environment.GetCommandLineArgs();
-        if (args == null || args.Length == 0) args = new String[1];
-        args[0] = "-upgrade";
-        var gs = args.Join(" ");
+        var gs = BuildRestartArguments();
 
-        // 执行重启,如果失败,延迟后再次尝试
+        // 冒烟测试:尝试拉起新版本进程
         var rs = upgrade.Run(name, gs, 3_000);
         if (!rs)
         {
-            var delay = 3_000;
-            this.WriteInfoEvent("Upgrade", $"拉起新进程失败,延迟{delay}ms后重试");
+            // 首次失败可能是慢速存储落盘延迟或 JIT 冷启动,等待后重试一次
+            var delay = 5_000;
+            this.WriteInfoEvent("Upgrade", $"新版首次启动失败,等待{delay}ms后重试。{upgrade.LastErrorMessage}");
             Thread.Sleep(delay);
-            rs = upgrade.Run(name, gs, 1_000);
+            rs = upgrade.Run(name, gs, 3_000);
         }
 
         if (rs)
         {
+            // 新版启动成功(冒烟测试通过),旧版退出
             var pid = System.Diagnostics.Process.GetCurrentProcess().Id;
             this.WriteInfoEvent("Upgrade", "强制更新完成,新进程已拉起,准备退出当前进程!PID=" + pid);
 
@@ -1013,10 +1021,26 @@ public abstract class ClientBase : DisposeBase, IApiClient, ICommandClient, IEve
         }
         else
         {
-            this.WriteInfoEvent("Upgrade", "强制更新完成,但拉起新进程失败!" + upgrade.LastErrorMessage);
+            // 两次均失败,确认新版有致命缺陷,回滚 .del 恢复旧版文件,旧进程继续运行
+            upgrade.Rollback();
+            this.WriteInfoEvent("Upgrade", "新版启动失败(冒烟测试不通过),已回滚旧版文件,当前服务继续运行。" + upgrade.LastErrorMessage);
         }
     }
 
+    /// <summary>构建重启命令行参数。派生类可重载以支持服务模式等自定义逻辑</summary>
+    /// <remarks>
+    /// 默认行为:从当前进程命令行去掉 args[0](可执行文件路径),注入 -upgrade 标记。
+    /// MyStarClient 可重载此方法生成 -restart -upgrade(服务模式)或 -run -upgrade(进程模式)。
+    /// </remarks>
+    /// <returns>重启参数</returns>
+    protected virtual String BuildRestartArguments()
+    {
+        var args = Environment.GetCommandLineArgs();
+        if (args == null || args.Length == 0) args = new String[1];
+        args[0] = "-upgrade";
+        return args.Join(" ");
+    }
+
     /// <summary>放弃更新异步请求。由Upgrade内部调用</summary>
     /// <param name="channel">更新通道</param>
     /// <param name="cancellationToken">取消令牌</param>
Modified +359 -68
diff --git a/NewLife.Remoting/Clients/Upgrade.cs b/NewLife.Remoting/Clients/Upgrade.cs
index a607a54..a444230 100644
--- a/NewLife.Remoting/Clients/Upgrade.cs
+++ b/NewLife.Remoting/Clients/Upgrade.cs
@@ -3,6 +3,9 @@ using System.Net.Http;
 using System.Reflection;
 using NewLife.Log;
 using NewLife.Remoting.Models;
+#if !NET40
+using TaskEx = System.Threading.Tasks.Task;
+#endif
 
 namespace NewLife.Remoting.Clients;
 
@@ -33,6 +36,21 @@ public class Upgrade
 
     /// <summary>更新模式</summary>
     public UpdateModes Mode { get; set; } = UpdateModes.Standard;
+
+    /// <summary>下载超时时间(秒)。默认30秒</summary>
+    public Int32 DownloadTimeoutSeconds { get; set; } = 30;
+
+    /// <summary>落盘等待延迟(毫秒)。sync 后等待存储设备完成回写。Linux ARM64 慢速存储(eMMC/SD)默认 800ms,x86 默认 0</summary>
+    public Int32 StorageFlushDelay { get; set; } = GetDefaultFlushDelay();
+
+    private static Int32 GetDefaultFlushDelay()
+    {
+#if NETCOREAPP || NETSTANDARD2_0_OR_GREATER
+        if (Runtime.Linux && System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture == System.Runtime.InteropServices.Architecture.Arm64)
+            return 800;
+#endif
+        return 0;
+    }
     #endregion
 
     #region 最后错误
@@ -50,7 +68,7 @@ public class Upgrade
     #endregion
 
     #region 方法
-    /// <summary>开始下载更新</summary>
+    /// <summary>下载更新包,支持超时与瞬时失败重试</summary>
     public virtual async Task<Boolean> Download(CancellationToken cancellationToken = default)
     {
         var url = Url;
@@ -68,7 +86,23 @@ public class Upgrade
         var sw = Stopwatch.StartNew();
 
         var web = CreateClient();
-        file = await DownloadFileAsync(web, url, file, cancellationToken).ConfigureAwait(false);
+
+        // 瞬时网络故障重试两次(共三次尝试)
+        var retry = 2;
+        while (true)
+        {
+            try
+            {
+                file = await DownloadFileAsync(web, url, file, cancellationToken).ConfigureAwait(false);
+                break;
+            }
+            catch (Exception ex) when (ex is IOException || ex is HttpRequestException)
+            {
+                if (retry-- <= 0) throw;
+                WriteLog("下载失败,2秒后重试:{0}", ex.Message);
+                await TaskEx.Delay(2_000, cancellationToken).ConfigureAwait(false);
+            }
+        }
 
         sw.Stop();
         WriteLog("下载完成!{2} 大小{0:n0}字节,耗时{1:n0}ms", file.AsFile().Length, sw.ElapsedMilliseconds, file);
@@ -117,7 +151,7 @@ public class Upgrade
         return true;
     }
 
-    /// <summary>执行更新,拷贝文件</summary>
+    /// <summary>执行更新,拷贝文件。按更新模式清理目标目录</summary>
     public virtual Boolean Update()
     {
         var dest = DestinationPath;
@@ -135,39 +169,102 @@ public class Upgrade
         var dic = new Dictionary<String, String>();
         try
         {
-            //!!! 此处递归删除,导致也删掉了Update里面的文件
-            // 更新覆盖之前,需要把exe/dll可执行文件移走,否则Linux下覆盖运行中文件会报段错误
-            foreach (var item in dest.AsDirectory().GetAllFiles("*.exe;*.dll", false))
+            switch (Mode)
             {
-                var ori = item.FullName;
-                var del = $"{item.FullName}.del";
-                WriteLog("MoveTo {0}", del);
-                try
-                {
-                    //if (File.Exists(del)) File.Delete(del);
-                    // 如果.del文件已存在,不能直接删,因为进程可能正在使用(上次升级未完成)。
-                    if (File.Exists(del)) del = $"{item.FullName}_{DateTime.Now:yyMMddHHmmss}.del";
-                    item.MoveTo(del);
-
-                    dic[ori] = del;
-                }
-                catch (Exception ex)
-                {
-                    WriteLog(ex.Message);
-
-                    try
+                case UpdateModes.Full:
+                    // 完整包:清空目标目录(保护配置、更新目录、备份和日志)
+                    WriteLog("完整包模式,清空目标目录");
                     {
-                        // 删除失败时,移动到临时目录随机文件
-                        var target = Path.GetTempFileName();
-                        item.MoveTo(target);
-
-                        dic[ori] = target;
+                        var protectedNames = new[] { "appsettings.json", "appsettings.*.json", "Update", "Log", "Config" };
+                        foreach (var item in dest.AsDirectory().GetAllFiles(null, false))
+                        {
+                            if (!item.Name.EndsWithIgnoreCase(".del") && !protectedNames.Any(e => item.Name.EqualIgnoreCase(e)))
+                            {
+                                var ori = item.FullName;
+                                var del = $"{ori}.del";
+                                if (File.Exists(del)) del = $"{ori}_{DateTime.Now:yyMMddHHmmss}.del";
+                                WriteLog("MoveTo {0}", del);
+                                item.MoveTo(del);
+                                dic[ori] = del;
+                            }
+                        }
+                        // 递归清理子目录(保留 Update/ Log/ Config/)
+                        foreach (var dir in dest.AsDirectory().GetDirectories())
+                        {
+                            if (protectedNames.Any(e => dir.Name.EqualIgnoreCase(e))) continue;
+                            WriteLog("DeleteDir {0}", dir.FullName);
+                            dir.Delete(true);
+                        }
                     }
-                    catch (Exception ex2)
+                    break;
+                case UpdateModes.Partial:
+                    // 部分包:不清理目标,直接覆盖
+                    WriteLog("部分包模式,直接覆盖");
+                    break;
+                case UpdateModes.Standard:
+                default:
+                    //!!! 此处递归删除,导致也删掉了Update里面的文件
+                    // 更新覆盖之前,需要把exe/dll可执行文件移走,否则Linux下覆盖运行中文件会报段错误
+                    foreach (var item in dest.AsDirectory().GetAllFiles("*.exe;*.dll", false))
                     {
-                        WriteLog(ex2.Message);
+                        var ori = item.FullName;
+                        var del = $"{item.FullName}.del";
+                        WriteLog("MoveTo {0}", del);
+                        try
+                        {
+                            //if (File.Exists(del)) File.Delete(del);
+                            // 如果.del文件已存在,不能直接删,因为进程可能正在使用(上次升级未完成)。
+                            if (File.Exists(del)) del = $"{item.FullName}_{DateTime.Now:yyMMddHHmmss}.del";
+                            item.MoveTo(del);
+
+                            dic[ori] = del;
+                        }
+                        catch (Exception ex)
+                        {
+                            WriteLog(ex.Message);
+
+                            try
+                            {
+                                // 删除失败时,移动到临时目录随机文件
+                                var target = Path.GetTempFileName();
+                                item.MoveTo(target);
+
+                                dic[ori] = target;
+                            }
+                            catch (Exception ex2)
+                            {
+                                WriteLog(ex2.Message);
+                            }
+                        }
                     }
-                }
+
+                    // 备份将被覆盖的 JSON 文件(runtimeconfig.json / deps.json 等随 .NET 版本变化的文件),确保 Rollback 能完整恢复
+                    {
+                        var jsonPatterns = new[] { "*.runtimeconfig.json", "*.deps.json" };
+                        foreach (var pattern in jsonPatterns)
+                        {
+                            foreach (var item in dest.AsDirectory().GetAllFiles(pattern, false))
+                            {
+                                // 跳过已在 exe/dll 环节备份的文件
+                                if (dic.ContainsKey(item.FullName)) continue;
+
+                                var ori = item.FullName;
+                                var del = $"{ori}.del";
+                                WriteLog("MoveTo {0}", del);
+                                try
+                                {
+                                    if (File.Exists(del)) del = $"{ori}_{DateTime.Now:yyMMddHHmmss}.del";
+                                    item.MoveTo(del);
+                                    dic[ori] = del;
+                                }
+                                catch (Exception ex)
+                                {
+                                    WriteLog(ex.Message);
+                                }
+                            }
+                        }
+                    }
+                    break;
             }
 
             // 拷贝替换更新
@@ -177,6 +274,20 @@ public class Upgrade
             //DeleteBackup(DestinationPath);
             //!!! 先别急着删除,在Linux上,删除正在使用的文件可能导致进程崩溃
 
+            // 强制 fsync 将页缓存提交到存储介质。同一启动周期内 exec() 通过页缓存读取文件是内核级一致的,
+            // sync 的主要价值是防止系统崩溃/断电重启后读到半写入数据。
+            if (Runtime.Linux)
+            {
+                Process.Start(new ProcessStartInfo("sync", "") { UseShellExecute = false })?.WaitForExit(10_000);
+
+                // ARM64 慢速存储额外等待页缓存回写完成
+                if (StorageFlushDelay > 0)
+                {
+                    WriteLog("等待落盘 {0}ms(慢速存储保护)", StorageFlushDelay);
+                    Thread.Sleep(StorageFlushDelay);
+                }
+            }
+
             WriteLog("更新成功!");
         }
         catch
@@ -223,72 +334,168 @@ public class Upgrade
         }
     }
 
+    /// <summary>回滚更新。将目标目录中备份的 .del 文件恢复为原始文件,用于新版本启动失败(冒烟测试不通过)时回退到旧版本</summary>
+    /// <remarks>
+    /// 设计理念:自更新最怕新版本有 bug 把自己"玩死"。
+    /// 更新完成后旧进程尝试拉起新进程作为冒烟测试——若新版本在 msWait 内异常退出,
+    /// 说明新版本存在致命缺陷,此时回滚所有 .del 恢复旧版文件,旧进程继续提供服务。
+    /// 即使设备随后重启,加载的仍是经过验证的旧版本。
+    /// </remarks>
+    /// <returns>恢复的文件数量</returns>
+    public Int32 Rollback()
+    {
+        var dest = DestinationPath;
+        if (dest.IsNullOrEmpty()) return 0;
+
+        var count = 0;
+        // 扫描目标目录中的 .del 文件(含时间戳变体 xxx_yyMMddHHmmss.del)
+        foreach (var item in dest.AsDirectory().GetAllFiles("*.del", false))
+        {
+            var delName = item.FullName;
+            // 去掉 .del 后缀得到原始文件名
+            var ori = delName.TrimSuffix(".del");
+
+            // 时间戳变体:xxx_yyMMddHHmmss.del → 原始文件名为 xxx(去掉 _12位时间戳)
+            var p = ori.LastIndexOf('_');
+            if (p > 0)
+            {
+                var suffix = ori.Substring(p + 1);
+                // 判断是否为 12 位纯数字时间戳(避免误判含下划线的正常文件名)
+                if (suffix.Length == 12 && suffix.All(Char.IsDigit))
+                    ori = ori.Substring(0, p);
+            }
+
+            WriteLog("Rollback {0} -> {1}", item.Name, Path.GetFileName(ori));
+            try
+            {
+                if (File.Exists(ori)) File.Delete(ori);
+                File.Move(delName, ori);
+                count++;
+            }
+            catch (Exception ex)
+            {
+                WriteLog("回滚失败:{0}", ex.Message);
+            }
+        }
+
+        if (count > 0) WriteLog("回滚完成,共恢复 {0} 个文件", count);
+
+        return count;
+    }
+
     /// <summary>启动当前应用的新进程。当前进程退出</summary>
     public Boolean Run(String name, String args) => Run(name, args, 3_000);
 
-    /// <summary>启动当前应用的新进程。当前进程退出</summary>
-    /// <param name="name"></param>
-    /// <param name="args"></param>
-    /// <param name="msWait"></param>
-    /// <returns></returns>
+    /// <summary>启动当前应用的新进程</summary>
+    /// <param name="name">应用名称(不含扩展名)。如 StarAgent</param>
+    /// <param name="args">启动参数</param>
+    /// <param name="msWait">等待毫秒数。若进程在此期间未退出视为启动成功</param>
+    /// <returns>是否成功拉起</returns>
     public Boolean Run(String name, String args, Int32 msWait)
     {
-        var file = "";
-        if (Runtime.Windows || Runtime.Mono)
-            file = name + ".exe";
-        else if (Runtime.Linux)
-            file = name;
+        // 通过 runtimeconfig.json 判定是否为 .NET Core 应用(muxer 优先)
+        var runtimeConfig = $"{name}.runtimeconfig.json".GetFullPath();
+        var isNetCore = File.Exists(runtimeConfig);
+
+        String fileName;
+        String? dotnetPath = null;
+        if (isNetCore)
+        {
+            // .NET Core 应用:使用 dotnet <name>.dll <args>,跨平台标准写法
+            fileName = (name + ".dll").GetFullPath();
+            if (!File.Exists(fileName)) throw new FileNotFoundException($"未找到 .NET 应用入口:{fileName}");
+
+            dotnetPath = ResolveDotNetPath();
+        }
         else
-            file = name + ".dll";
+        {
+            // 非 .NET Core 应用:按平台选择可执行文件
+            if (Runtime.Windows || Runtime.Mono)
+                fileName = (name + ".exe").GetFullPath();
+            else if (Runtime.Linux)
+                fileName = name.GetFullPath();
+            else
+                fileName = (name + ".dll").GetFullPath();
+
+            // 如果入口文件不存在,则尝试 dll 启动(.NET Framework 也可能用 dotnet 启动)
+            if (!File.Exists(fileName))
+            {
+                var dllFile = (name + ".dll").GetFullPath();
+                if (File.Exists(dllFile))
+                {
+                    fileName = dllFile;
+                    dotnetPath = ResolveDotNetPath();
+                    isNetCore = true;
+                }
+            }
 
-        file = file.GetFullPath();
+            if (!File.Exists(fileName)) throw new FileNotFoundException($"未找到可执行文件:{fileName}");
+        }
 
-        // 如果入口文件不存在,则直接使用dll启动
-        if (!File.Exists(file))
-            file = (name + ".dll").GetFullPath();
-        else if (Runtime.Linux)
+        // Linux 上对新二进制文件执行 chmod +x,确保有执行权限
+        if (Runtime.Linux && !isNetCore)
         {
             // 等待 chmod 完成(WaitForExit),避免 fire-and-forget 导致权限未及时生效的竞态条件。
             // 在慢速 ARM64 设备上,升级刚完成后系统 I/O 繁忙,chmod 可能超过 1 秒才能完成,
             // 若此时新进程尚无执行权限,UseShellExecute=false 会抛出 EACCES 导致启动失败。
             // 使用绝对路径 /bin/chmod 避免 PATH 解析,UseShellExecute=false 确保同步等待。
-            var p = Process.Start(new ProcessStartInfo("/bin/chmod", "+x " + file) { UseShellExecute = false });
+            var p = Process.Start(new ProcessStartInfo("/bin/chmod", "+x " + fileName) { UseShellExecute = false });
             p?.WaitForExit(5_000);
         }
 
-        WriteLog("拉起进程 {0} {1}", file, args);
+        WriteLog("拉起进程 {0} {1}", fileName, args);
         try
         {
-            var workingDir = Path.GetDirectoryName(file)!;
+            var workingDir = Path.GetDirectoryName(fileName)!;
             Process? p;
-            if (file.EndsWithIgnoreCase(".dll"))
+
+            if (isNetCore)
             {
-                // 使用 UseShellExecute=false 直接 fork/exec,避免 systemd 隔离环境下 UseShellExecute=true 走 shell 导致的 PATH 解析失败问题。
-                // systemd 服务的工作目录和 PATH 环境变量可能与用户登录 shell 不同,UseShellExecute=true 在 .NET 7+ Linux 上通过 shell 启动,
-                // 在精简环境中可能因找不到 dotnet 而抛出异常或返回 null。改为 false 后直接指定 FileName 和 Arguments,
-                // 同时显式设置 WorkingDirectory 确保新进程在正确的目录下启动。
-                p = Process.Start(new ProcessStartInfo
+                var startInfo = new ProcessStartInfo
                 {
-                    FileName = "dotnet",
-                    Arguments = $"{file} {args}",
+                    FileName = dotnetPath!,
+                    Arguments = $"{fileName} {args}",
                     WorkingDirectory = workingDir,
                     UseShellExecute = false,
-                });
+                    RedirectStandardError = true,
+                };
+
+                // 注入 DOTNET_ROOT 环境变量,确保子进程在 systemd 隔离环境下找到运行时
+                InjectDotNetRoot(startInfo, dotnetPath!);
+
+                p = Process.Start(startInfo);
             }
             else
             {
                 // 直接执行原生可执行文件(Linux 上为无扩展名的二进制文件),同样避免 shell 层
                 p = Process.Start(new ProcessStartInfo
                 {
-                    FileName = file,
+                    FileName = fileName,
                     Arguments = args,
                     WorkingDirectory = workingDir,
                     UseShellExecute = false,
+                    RedirectStandardError = true,
                 });
             }
 
-            // 如果进程在指定时间退出,说明启动失败
-            return p != null && (!p.WaitForExit(msWait) || p.ExitCode == 0);
+            if (p == null) return false;
+
+            // 如果进程在指定时间内退出,说明启动失败,捕获 stderr 用于诊断
+            if (p.WaitForExit(msWait))
+            {
+                if (p.ExitCode == 0) return true;
+
+                // 非零退出:读取 stderr 写入 LastErrorMessage
+                var stderr = p.StandardError.ReadToEnd();
+                LastErrorMessage = !stderr.IsNullOrEmpty()
+                    ? $"ExitCode={p.ExitCode}, stderr={stderr}"
+                    : $"ExitCode={p.ExitCode}(无错误输出)";
+                WriteLog("启动进程失败:{0}", LastErrorMessage);
+                return false;
+            }
+
+            // 进程持续运行 → 成功
+            return true;
         }
         catch (Exception ex)
         {
@@ -300,21 +507,102 @@ public class Upgrade
         }
     }
 
+    /// <summary>解析 dotnet 可执行文件路径</summary>
+    /// <remarks>
+    /// 优先级:DOTNET_ROOT 环境变量 → /usr/bin/dotnet(Linux 标准软链接,NetRuntime.InstallOnLinux 创建)→ /usr/local/bin/dotnet → 回退 "dotnet"
+    /// </remarks>
+    /// <returns>dotnet 可执行文件路径</returns>
+    internal static String ResolveDotNetPath()
+    {
+        // 优先使用 DOTNET_ROOT 环境变量
+        var dotnetRoot = Environment.GetEnvironmentVariable("DOTNET_ROOT");
+        if (!dotnetRoot.IsNullOrEmpty())
+        {
+            var path = Path.Combine(dotnetRoot, "dotnet");
+            if (File.Exists(path)) return path;
+        }
+
+        // Linux 标准路径:/usr/bin/dotnet(NetRuntime.InstallOnLinux 创建的软链接)
+        if (Runtime.Linux)
+        {
+            if (File.Exists("/usr/bin/dotnet")) return "/usr/bin/dotnet";
+            if (File.Exists("/usr/local/bin/dotnet")) return "/usr/local/bin/dotnet";
+        }
+
+        // 回退到系统 PATH
+        return "dotnet";
+    }
+
+    /// <summary>向子进程注入 DOTNET_ROOT 环境变量,确保在 systemd 隔离环境下找到运行时</summary>
+    /// <param name="startInfo">进程启动信息</param>
+    /// <param name="dotnetPath">已解析的 dotnet 路径</param>
+    static void InjectDotNetRoot(ProcessStartInfo startInfo, String dotnetPath)
+    {
+#if NET5_0_OR_GREATER
+        // 如果 dotnetPath 是绝对路径,反推运行时根目录
+        if (Path.IsPathRooted(dotnetPath) && Runtime.Linux)
+        {
+            // /usr/bin/dotnet → /usr, /usr/share/dotnet/dotnet → /usr/share/dotnet
+            var binDir = Path.GetDirectoryName(dotnetPath)!;
+            var dotnetRoot = Path.GetDirectoryName(binDir);
+
+            // /usr/bin/dotnet → 运行时在 /usr/share/dotnet 而不是 /usr
+            if (dotnetRoot != null && (dotnetRoot.EndsWith("/bin") || dotnetRoot!.EndsWith("\\bin")))
+                dotnetRoot = Path.Combine(Path.GetDirectoryName(dotnetRoot)!, "share", "dotnet");
+
+            if (!dotnetRoot.IsNullOrEmpty() && Directory.Exists(dotnetRoot))
+                startInfo.Environment["DOTNET_ROOT"] = dotnetRoot;
+        }
+
+        // 注入当前进程的 DOTNET_ROOT(如果有)
+        var currentRoot = Environment.GetEnvironmentVariable("DOTNET_ROOT");
+        if (!currentRoot.IsNullOrEmpty() && !startInfo.Environment.ContainsKey("DOTNET_ROOT"))
+            startInfo.Environment["DOTNET_ROOT"] = currentRoot;
+
+        // 注入架构特定的 DOTNET_ROOT 变量(ARM64/x64)
+        if (startInfo.Environment.TryGetValue("DOTNET_ROOT", out var root) && !root.IsNullOrEmpty())
+        {
+            var arch = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture;
+            if (arch == System.Runtime.InteropServices.Architecture.Arm64)
+                startInfo.Environment["DOTNET_ROOT_ARM64"] = root;
+            else if (arch == System.Runtime.InteropServices.Architecture.X64)
+                startInfo.Environment["DOTNET_ROOT_X64"] = root;
+        }
+#else
+        // .NET Framework:使用 EnvironmentVariables
+        if (Path.IsPathRooted(dotnetPath) && Runtime.Linux)
+        {
+            var binDir = Path.GetDirectoryName(dotnetPath)!;
+            var dotnetRoot = Path.GetDirectoryName(binDir);
+
+            if (dotnetRoot != null && (dotnetRoot.EndsWith("/bin") || dotnetRoot!.EndsWith("\\bin")))
+                dotnetRoot = Path.Combine(Path.GetDirectoryName(dotnetRoot)!, "share", "dotnet");
+
+            if (!dotnetRoot.IsNullOrEmpty() && Directory.Exists(dotnetRoot))
+                startInfo.EnvironmentVariables["DOTNET_ROOT"] = dotnetRoot;
+        }
+
+        var currentRoot2 = Environment.GetEnvironmentVariable("DOTNET_ROOT");
+        if (!currentRoot2.IsNullOrEmpty() && !startInfo.EnvironmentVariables.ContainsKey("DOTNET_ROOT"))
+            startInfo.EnvironmentVariables["DOTNET_ROOT"] = currentRoot2;
+#endif
+    }
+
     static Process? RunShell(String fileName, String args) => Process.Start(new ProcessStartInfo(fileName, args) { UseShellExecute = true });
     #endregion
 
     #region 辅助
-    /// <summary>
-    /// 自杀
-    /// </summary>
+    /// <summary>终止当前进程</summary>
     public virtual void KillSelf()
     {
         var p = Process.GetCurrentProcess();
         WriteLog("退出当前进程 {0}", p.Id);
 
-        if (!Runtime.IsConsole) p.CloseMainWindow();
+        if (!Runtime.IsConsole)
+            p.CloseMainWindow();
+
+        // 直接退出进程,不再执行任何代码
         Environment.Exit(0);
-        p.Kill();
     }
 
     /// <summary>
@@ -364,7 +652,10 @@ public class Upgrade
     {
         if (_Client != null) return _Client;
 
-        return _Client = new HttpClient();
+        return _Client = new HttpClient
+        {
+            Timeout = TimeSpan.FromSeconds(DownloadTimeoutSeconds),
+        };
     }
 
     /// <summary>下载文件</summary>
Modified +252 -1
diff --git a/XUnitTest/Clients/UpgradeTests.cs b/XUnitTest/Clients/UpgradeTests.cs
index 2de0aec..ccc7d30 100644
--- a/XUnitTest/Clients/UpgradeTests.cs
+++ b/XUnitTest/Clients/UpgradeTests.cs
@@ -1,5 +1,6 @@
 using System;
 using System.ComponentModel;
+using System.IO;
 using System.Threading.Tasks;
 using NewLife.Remoting;
 using NewLife.Remoting.Clients;
@@ -23,6 +24,9 @@ public class UpgradeTests
         Assert.Null(upgrade.SourceFile);
         Assert.Null(upgrade.TempPath);
         Assert.Equal(UpdateModes.Standard, upgrade.Mode);
+        Assert.Equal(30, upgrade.DownloadTimeoutSeconds);
+        // StorageFlushDelay 在不同平台上值不同,仅验证 >=0
+        Assert.True(upgrade.StorageFlushDelay >= 0);
     }
 
     [Fact]
@@ -37,7 +41,9 @@ public class UpgradeTests
             Url = "http://example.com/update.zip",
             SourceFile = "/tmp/update.zip",
             TempPath = "/tmp/extract",
-            Mode = UpdateModes.Full
+            Mode = UpdateModes.Full,
+            DownloadTimeoutSeconds = 600,
+            StorageFlushDelay = 1000,
         };
 
         Assert.Equal("TestApp", upgrade.Name);
@@ -47,6 +53,8 @@ public class UpgradeTests
         Assert.Equal("/tmp/update.zip", upgrade.SourceFile);
         Assert.Equal("/tmp/extract", upgrade.TempPath);
         Assert.Equal(UpdateModes.Full, upgrade.Mode);
+        Assert.Equal(600, upgrade.DownloadTimeoutSeconds);
+        Assert.Equal(1000, upgrade.StorageFlushDelay);
     }
 
     [Fact]
@@ -136,4 +144,247 @@ public class UpgradeTests
 
         Assert.False(upgrade.Update());
     }
+
+    [Fact]
+    [DisplayName("ResolveDotNetPath返回非空字符串")]
+    public void ResolveDotNetPath_ReturnsNonEmpty()
+    {
+        var path = Upgrade.ResolveDotNetPath();
+
+        Assert.NotNull(path);
+        Assert.NotEmpty(path);
+    }
+
+    [Fact]
+    [DisplayName("DownloadTimeoutSeconds默认值30")]
+    public void DownloadTimeoutSeconds_Default()
+    {
+        var upgrade = new Upgrade();
+        Assert.Equal(30, upgrade.DownloadTimeoutSeconds);
+    }
+
+    [Fact]
+    [DisplayName("UpdateModes默认值为Standard")]
+    public void UpdateModes_Default()
+    {
+        var upgrade = new Upgrade();
+        Assert.Equal(UpdateModes.Standard, upgrade.Mode);
+    }
+
+    [Fact]
+    [DisplayName("UpdateModes所有枚举值可设置")]
+    public void UpdateModes_AllValues()
+    {
+        foreach (UpdateModes mode in Enum.GetValues(typeof(UpdateModes)))
+        {
+            var upgrade = new Upgrade { Mode = mode };
+            Assert.Equal(mode, upgrade.Mode);
+        }
+    }
+
+    [Fact]
+    [DisplayName("部分包模式下Update不移动已有文件")]
+    public void Update_PartialMode_DoesNotMoveExisting()
+    {
+        // 创建临时目录结构
+        var destDir = Path.Combine(Path.GetTempPath(), "UpgradeTest_" + Guid.NewGuid().ToString("N"));
+        var tmpDir = Path.Combine(Path.GetTempPath(), "UpgradeTest_Tmp_" + Guid.NewGuid().ToString("N"));
+        try
+        {
+            Directory.CreateDirectory(destDir);
+            Directory.CreateDirectory(tmpDir);
+
+            // 在目标目录创建模拟 exe 和 dll
+            var testExe = Path.Combine(destDir, "test.exe");
+            var testDll = Path.Combine(destDir, "test.dll");
+            File.WriteAllText(testExe, "old");
+            File.WriteAllText(testDll, "old");
+
+            // 在临时目录创建新文件
+            File.WriteAllText(Path.Combine(tmpDir, "newfile.txt"), "new");
+
+            var upgrade = new Upgrade
+            {
+                DestinationPath = destDir,
+                TempPath = tmpDir,
+                Mode = UpdateModes.Partial,
+            };
+
+            var result = upgrade.Update();
+
+            Assert.True(result);
+            // 原文件不应该被移走(没有被改为 .del)
+            Assert.True(File.Exists(testExe));
+            Assert.True(File.Exists(testDll));
+            Assert.False(File.Exists(testExe + ".del"));
+            Assert.False(File.Exists(testDll + ".del"));
+            // 新文件已拷贝
+            Assert.True(File.Exists(Path.Combine(destDir, "newfile.txt")));
+        }
+        finally
+        {
+            if (Directory.Exists(destDir)) Directory.Delete(destDir, true);
+            if (Directory.Exists(tmpDir)) Directory.Delete(tmpDir, true);
+        }
+    }
+
+    [Fact]
+    [DisplayName("标准包模式下Update将exe和dll移为del")]
+    public void Update_StandardMode_MovesExeAndDll()
+    {
+        var destDir = Path.Combine(Path.GetTempPath(), "UpgradeTest_" + Guid.NewGuid().ToString("N"));
+        var tmpDir = Path.Combine(Path.GetTempPath(), "UpgradeTest_Tmp_" + Guid.NewGuid().ToString("N"));
+        try
+        {
+            Directory.CreateDirectory(destDir);
+            Directory.CreateDirectory(tmpDir);
+
+            var testExe = Path.Combine(destDir, "test.exe");
+            var testDll = Path.Combine(destDir, "test.dll");
+            var testTxt = Path.Combine(destDir, "data.txt");
+            File.WriteAllText(testExe, "old");
+            File.WriteAllText(testDll, "old");
+            File.WriteAllText(testTxt, "data");
+
+            File.WriteAllText(Path.Combine(tmpDir, "newfile.txt"), "new");
+
+            var upgrade = new Upgrade
+            {
+                DestinationPath = destDir,
+                TempPath = tmpDir,
+                Mode = UpdateModes.Standard,
+            };
+
+            var result = upgrade.Update();
+
+            Assert.True(result);
+            // exe/dll 被移走(变为 .del)
+            Assert.True(File.Exists(testExe + ".del") || !File.Exists(testExe));
+            Assert.True(File.Exists(testDll + ".del") || !File.Exists(testDll));
+            // 非可执行文件保留
+            Assert.True(File.Exists(testTxt));
+        }
+        finally
+        {
+            if (Directory.Exists(destDir)) Directory.Delete(destDir, true);
+            if (Directory.Exists(tmpDir)) Directory.Delete(tmpDir, true);
+        }
+    }
+
+    [Fact]
+    [DisplayName("完整包模式下Update清空目标目录但保留配置")]
+    public void Update_FullMode_CleansDestination()
+    {
+        var destDir = Path.Combine(Path.GetTempPath(), "UpgradeTest_" + Guid.NewGuid().ToString("N"));
+        var tmpDir = Path.Combine(Path.GetTempPath(), "UpgradeTest_Tmp_" + Guid.NewGuid().ToString("N"));
+        try
+        {
+            Directory.CreateDirectory(destDir);
+            Directory.CreateDirectory(tmpDir);
+
+            File.WriteAllText(Path.Combine(destDir, "test.exe"), "old");
+            File.WriteAllText(Path.Combine(destDir, "test.dll"), "old");
+            File.WriteAllText(Path.Combine(destDir, "appsettings.json"), "config");
+            File.WriteAllText(Path.Combine(tmpDir, "newfile.txt"), "new");
+
+            var upgrade = new Upgrade
+            {
+                DestinationPath = destDir,
+                TempPath = tmpDir,
+                Mode = UpdateModes.Full,
+            };
+
+            var result = upgrade.Update();
+
+            Assert.True(result);
+            // 配置文件保留
+            Assert.True(File.Exists(Path.Combine(destDir, "appsettings.json")));
+            // 新文件已拷贝
+            Assert.True(File.Exists(Path.Combine(destDir, "newfile.txt")));
+        }
+        finally
+        {
+            if (Directory.Exists(destDir)) Directory.Delete(destDir, true);
+            if (Directory.Exists(tmpDir)) Directory.Delete(tmpDir, true);
+        }
+    }
+
+    [Fact]
+    [DisplayName("Rollback恢复del文件")]
+    public void Rollback_RestoresDelFiles()
+    {
+        var destDir = Path.Combine(Path.GetTempPath(), "UpgradeTest_" + Guid.NewGuid().ToString("N"));
+        try
+        {
+            Directory.CreateDirectory(destDir);
+
+            // 模拟标准模式:创建 .dll.del 和 .exe.del 备份文件
+            var testExeDel = Path.Combine(destDir, "test.exe.del");
+            var testDllDel = Path.Combine(destDir, "test.dll.del");
+            File.WriteAllText(testExeDel, "backup");
+            File.WriteAllText(testDllDel, "backup");
+
+            var upgrade = new Upgrade { DestinationPath = destDir };
+            var count = upgrade.Rollback();
+
+            Assert.Equal(2, count);
+            // 原始文件已恢复
+            Assert.True(File.Exists(Path.Combine(destDir, "test.exe")));
+            Assert.True(File.Exists(Path.Combine(destDir, "test.dll")));
+            // .del 备份文件已消失
+            Assert.False(File.Exists(testExeDel));
+            Assert.False(File.Exists(testDllDel));
+        }
+        finally
+        {
+            if (Directory.Exists(destDir)) Directory.Delete(destDir, true);
+        }
+    }
+
+    [Fact]
+    [DisplayName("Rollback恢复时间戳变体del文件")]
+    public void Rollback_RestoresTimestampedDelFiles()
+    {
+        var destDir = Path.Combine(Path.GetTempPath(), "UpgradeTest_" + Guid.NewGuid().ToString("N"));
+        try
+        {
+            Directory.CreateDirectory(destDir);
+
+            // 模拟时间戳变体:test.dll → 已存在 .del 所以生成 test.dll_260608153000.del
+            var delFile = Path.Combine(destDir, "test.dll_260608153000.del");
+            File.WriteAllText(delFile, "backup");
+
+            var upgrade = new Upgrade { DestinationPath = destDir };
+            var count = upgrade.Rollback();
+
+            Assert.Equal(1, count);
+            // 原始文件已恢复(去掉 _12位时间戳.del)
+            Assert.True(File.Exists(Path.Combine(destDir, "test.dll")));
+            Assert.False(File.Exists(delFile));
+        }
+        finally
+        {
+            if (Directory.Exists(destDir)) Directory.Delete(destDir, true);
+        }
+    }
+
+    [Fact]
+    [DisplayName("Rollback空目录返回0")]
+    public void Rollback_EmptyDir()
+    {
+        var destDir = Path.Combine(Path.GetTempPath(), "UpgradeTest_" + Guid.NewGuid().ToString("N"));
+        try
+        {
+            Directory.CreateDirectory(destDir);
+
+            var upgrade = new Upgrade { DestinationPath = destDir };
+            var count = upgrade.Rollback();
+
+            Assert.Equal(0, count);
+        }
+        finally
+        {
+            if (Directory.Exists(destDir)) Directory.Delete(destDir, true);
+        }
+    }
 }