From b0ff1a124f39a9b1ab23eca953c9a66410b7cf68 Mon Sep 17 00:00:00 2001 From: wangzhizhou <824219521@qq.com> Date: Thu, 5 Nov 2026 17:17:02 +0800 Subject: [PATCH] fix: never stop the updater from inside its own self-update Replacing the updater bundle ran stopHost first, whose lsof-and-SIGTERM lookup found the very process performing the install, so the transaction died after swapping the host and before writing the receipt, leaving staging behind and the updater untouched. stopsRunningInstance now decides once: production stops the host, the updater target does not, because a running bundle can be renamed and the next launch picks up the new one. Version 1.0.2, build 37. Signed-off-by: wangzhizhou <824219521@qq.com> --- Cargo.lock | 16 +++--- Cargo.toml | 12 ++-- docs/reviews/226-self-update-kills-itself.md | 55 +++++++++++++++++++ ...63\347\255\226\350\256\260\345\275\225.md" | 25 +++++++++ packaging/build-number | 2 +- .../DefaultInstallEnvironment.swift | 11 ++++ platforms/macos/tests/install-core.swift | 4 ++ 7 files changed, 110 insertions(+), 15 deletions(-) create mode 100644 docs/reviews/226-self-update-kills-itself.md diff --git a/Cargo.lock b/Cargo.lock index 633da97..c3bf110 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -25,7 +25,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "ime-cli" -version = "1.0.1" +version = "1.0.2" dependencies = [ "ime-config", "ime-core", @@ -35,7 +35,7 @@ dependencies = [ [[package]] name = "ime-config" -version = "1.0.1" +version = "1.0.2" dependencies = [ "arc-swap", "toml_edit", @@ -43,15 +43,15 @@ dependencies = [ [[package]] name = "ime-core" -version = "1.0.1" +version = "1.0.2" [[package]] name = "ime-dict" -version = "1.0.1" +version = "1.0.2" [[package]] name = "ime-ffi" -version = "1.0.1" +version = "1.0.2" dependencies = [ "ime-config", "ime-core", @@ -62,14 +62,14 @@ dependencies = [ [[package]] name = "ime-pinyin" -version = "1.0.1" +version = "1.0.2" dependencies = [ "ime-core", ] [[package]] name = "ime-table" -version = "1.0.1" +version = "1.0.2" dependencies = [ "ime-core", ] @@ -131,4 +131,4 @@ dependencies = [ [[package]] name = "wubi-encoder" -version = "1.0.1" +version = "1.0.2" diff --git a/Cargo.toml b/Cargo.toml index 4f3678a..b9185d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package] -version = "1.0.1" +version = "1.0.2" edition = "2021" license = "GPL-3.0-or-later" rust-version = "1.98" @@ -12,11 +12,11 @@ description = "萤火虫输入法:跨平台、离线、只做核心输入的 ## 内部路径依赖:显式写出 version 供 cargo-deny 解析(否则会被判为通配符 / 解析失败)。 ## 发版时与 `[workspace.package] version` 一起改。 [workspace.dependencies] -ime-dict = { path = "crates/ime-dict", version = "1.0.1" } -ime-config = { path = "crates/ime-config", version = "1.0.1" } -ime-core = { path = "crates/ime-core", version = "1.0.1" } -ime-pinyin = { path = "crates/ime-pinyin", version = "1.0.1" } -ime-table = { path = "crates/ime-table", version = "1.0.1" } +ime-dict = { path = "crates/ime-dict", version = "1.0.2" } +ime-config = { path = "crates/ime-config", version = "1.0.2" } +ime-core = { path = "crates/ime-core", version = "1.0.2" } +ime-pinyin = { path = "crates/ime-pinyin", version = "1.0.2" } +ime-table = { path = "crates/ime-table", version = "1.0.2" } [profile.release] lto = true diff --git a/docs/reviews/226-self-update-kills-itself.md b/docs/reviews/226-self-update-kills-itself.md new file mode 100644 index 0000000..04ce8ae --- /dev/null +++ b/docs/reviews/226-self-update-kills-itself.md @@ -0,0 +1,55 @@ +# 226 更新器自更新把自己杀了(ADR-306) + +> 背景:v1.0.0 → v1.0.1 的真机升级演练(第一次由 build 34 更新程序执行)。 +> 目标:闭环长期挂着的验收项「更新器自更新」。 + +## 1. 现象(2026-11-05,air) + +用户点「安装更新」后: + +| 项 | 期望 | 实际 | +|---|---|---| +| 宿主 | 1.0.1/36 | **36** ✅ | +| 更新程序 | 36 | **34** ❌(bundle mtime 仍是 10-30) | +| `updates/receipts/` | 写入回执 | **目录不存在** ❌ | +| 备份 | 成功保留 | `upgrade-…/backup/Lampyris.app` = 1.0.0 ✅ | +| staging | 成功清理 | `upgrade-…/staging` 与 `~/Applications/.lampyris-install-…` 残留 ❌ | + +同样的半成品状态复现了两次(两次之间把现场清理并装回 1.0.0/34)。 + +## 2. 排除"逻辑 bug" + +用仓库里的 `UpdateInstaller.install` 在**临时目录**跑同一套参数(真实 1.0.1 包、appcast 里的 +`edSignature`、包内 `SUPublicEDKey`): + +``` +payloadSHA256 = cba74da4… installedExec = 5d760c8a… +previous=nil updaterBundle=…/Applications/LampyrisUpdateCheck.app +receiptsExists = true +``` + +再把它指向**生产路径**(宿主已在 36,替换为同版本)——同样成功,更新程序被换成 36、回执写入。 +所以 `install` 逻辑本身没问题,差异在"更新程序正在替换它自己"。 + +## 3. 根因 + +`UpdateInstaller.replaceBundle` 在替换前统一调用 +`environment.stopHost(destination/)`。 +自更新传入的环境是 `DefaultInstallEnvironment.make(target: .updater)`,它的 `stopHost` +用 `lsof -t` 找该可执行文件的持有者并 `SIGTERM`——**持有者就是正在跑这次安装的更新程序**。 +于是:宿主换好 → 尝试自更新 → 自己被杀 → 回执未写、staging 未清、`defer` 不执行。 +这与 ADR-295 的注释「运行中的实例保持不动,下次启动生效」正好相反。 + +为什么以前没发现:单测用的是注入的假环境(`stopHost` 空实现);隔离演练(`--drill-run`)里 +`applications == nil`,根本不做自更新。所以真实路径从未被走过——这也解释了该验收项为何长期 BLOCKED。 + +## 4. 修法(ADR-306) + +`DefaultInstallEnvironment.stopsRunningInstance(_:)` 成为唯一判定点:`.updater` → false(不停), +`.production` → true。`stopHost` 开头判定后直接返回。 + +## 5. 验证 + +- `install-core.swift` 新增两条断言(生产停、自更新不停) +- 真机复验:见本报告后续追加(将在 1.0.2 发布后执行:更新程序 ≥ 宿主版本的一次安装, + 检查回执写入、staging 清理、更新程序被替换且进程不再自杀) diff --git "a/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" "b/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" index d9f44d4..e6d4b7a 100644 --- "a/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" +++ "b/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" @@ -2790,6 +2790,31 @@ - **验证**:schema 单测(新 Enum 默认 `half`)、迁移单测(bool → 枚举)、`settings-presentation` 断言控件类型为下拉且四项同构、`test-controller-menu` 与 `input-menu` 回归、装机后「按 ⌥F → 面板立即变」与「面板改 → 候选窗芯片变」双向验证。 - **关联**:报告 225、`docs/配置架构.md` §253/§265、`crates/ime-config/src/schema.rs`、`platforms/macos/Sources/ImeSession.swift`。 +## ADR-306:更新程序自更新时不得停掉正在运行的实例 + +- **编号**:306 +- **状态**:**已实施(1.0.2 / build 37)**。诊断见报告 226。 +- **背景**:1.0.0 → 1.0.1 的真机升级演练里,宿主升到了 36,但**更新程序仍是 34**, + `updates/receipts/` 从未创建,`upgrades/upgrade-…/staging` 与 `~/Applications/.lampyris-install-…` 残留。 + 用仓库代码在临时目录复现同一调用(真实包+签名+公钥)**完全成功**,说明逻辑本身没问题, + 差异只在"更新程序正在替换它自己"。 +- **根因**:`replaceBundle` 在替换前统一调用 `environment.stopHost(destination/<可执行>)`; + 自更新传的是 `DefaultInstallEnvironment.make(target: .updater)`,它的 `stopHost` 会 + `lsof` 该可执行文件并 `SIGTERM` 持有者——**持有者就是正在执行安装的更新程序自己**。 + 进程当场被杀,事务停在"宿主已换、回执未写"的半路,`defer` 也不会清理 staging。 + 这与 ADR-295 写下的意图("运行中的实例保持不动,下次启动生效")相反,属于实现与文档不一致。 +- **决策**:新增 `DefaultInstallEnvironment.stopsRunningInstance(_:)` 作为唯一判定点—— + `.updater` 目标**返回 false**(不停进程),`.production` 保持 true。`stopHost` 在开头做该判定。 + 运行中的 bundle 允许被 `rename` 替换(旧 inode 继续服务),下一次启动自然使用新版。 +- **理由**:① 自更新不需要停自己的进程,停了反而破坏事务;② 语义与 ADR-295 文案一致; + ③ 判定收敛成一个纯函数,可单测、可回归,不依赖真实 `lsof`/`kill`。 +- **边界**:不改安装顺序、receipt 内容、回滚策略;隔离构建(`applications == nil`)本来就不做自更新; + `.production` 的"停宿主再替换"不变(那是必要的,IMK 会重新拉起宿主)。 +- **验证**:`install-core.swift` 新增断言 `stopsRunningInstance(.production) == true`、 + `.updater == false`;真机复验走"更新程序版本 ≥ 宿主版本"的一次安装(报告 226 §4): + receipt 写入、staging 清理、更新程序 bundle 被成功替换且进程不再自杀。 +- **关联**:报告 226、ADR-295(自更新语义)、`DefaultInstallEnvironment.swift`、`UpdateInstaller.replaceBundle`。 + ## 待定项 | 编号 | 议题 | 说明 | diff --git a/packaging/build-number b/packaging/build-number index 7facc89..81b5c5d 100644 --- a/packaging/build-number +++ b/packaging/build-number @@ -1 +1 @@ -36 +37 diff --git a/platforms/macos/InstallCore/Sources/LampyrisInstallCore/DefaultInstallEnvironment.swift b/platforms/macos/InstallCore/Sources/LampyrisInstallCore/DefaultInstallEnvironment.swift index 284c3b0..71e4825 100644 --- a/platforms/macos/InstallCore/Sources/LampyrisInstallCore/DefaultInstallEnvironment.swift +++ b/platforms/macos/InstallCore/Sources/LampyrisInstallCore/DefaultInstallEnvironment.swift @@ -2,6 +2,16 @@ import Foundation /// Developer ID candidate validation and exact executable stop for the selected target. public enum DefaultInstallEnvironment { + /// 替换前是否需要停掉正在运行的同类实例。 + /// + /// **更新程序自更新时不能停**(ADR-306):执行自更新的进程正是它自己, + /// `stopHost` 会把当前进程 SIGTERM 掉,安装事务停在「宿主已换、回执未写」的半路, + /// 留下 staging 与缺失的 receipt。运行中的 bundle 允许被 rename 替换(旧 inode 继续服务), + /// 下次启动自然用新版,这正是 ADR-295 说的「运行中的实例保持不动」。 + public static func stopsRunningInstance(_ target: InstallTarget) -> Bool { + target.identity != InstallTarget.updater.identity + } + public static func make(target: InstallTarget = .production) -> InstallEnvironment { InstallEnvironment(validateCandidate: { app in let executable = app.appendingPathComponent(target.executablePath) @@ -27,6 +37,7 @@ public enum DefaultInstallEnvironment { try require("/usr/bin/xcrun", ["stapler", "validate", app.path]) try require("/usr/sbin/spctl", ["--assess", "--type", "execute", app.path]) }, stopHost: { executable in + guard stopsRunningInstance(target) else { return } let holders = run("/usr/sbin/lsof", ["-t", executable.path]) for value in holders.split(whereSeparator: { $0.isWhitespace }) { guard let pid = Int32(value) else { continue } diff --git a/platforms/macos/tests/install-core.swift b/platforms/macos/tests/install-core.swift index c701984..c6a428d 100644 --- a/platforms/macos/tests/install-core.swift +++ b/platforms/macos/tests/install-core.swift @@ -124,6 +124,10 @@ do { } require(mark(staged, "Lampyris") == "host-newest", "rejected installs must leave the existing bundle alone") +// 4c) 自更新绝不停掉正在运行的更新程序(ADR-306):停进程会让事务死在「宿主已换、回执未写」的半路。 +require(DefaultInstallEnvironment.stopsRunningInstance(.production), "production install must stop the host") +require(!DefaultInstallEnvironment.stopsRunningInstance(.updater), "self-update must never stop the updater") + // 5) self-update installs the sibling updater bundle into ~/Applications and keeps a backup let staging = root.appendingPathComponent("payload-staging") try! FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)