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)