Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 6 additions & 6 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
55 changes: 55 additions & 0 deletions docs/reviews/226-self-update-kills-itself.md
Original file line number Diff line number Diff line change
@@ -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/<target.executablePath>)`。
自更新传入的环境是 `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 清理、更新程序被替换且进程不再自杀)
25 changes: 25 additions & 0 deletions docs/决策记录.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`。

## 待定项

| 编号 | 议题 | 说明 |
Expand Down
2 changes: 1 addition & 1 deletion packaging/build-number
Original file line number Diff line number Diff line change
@@ -1 +1 @@
36
37
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 }
Expand Down
4 changes: 4 additions & 0 deletions platforms/macos/tests/install-core.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading