fix(desktop): recover interrupted backup rotations - #5647
huangruiteng merged 1 commit into
Conversation
Keep rollback available when the updater is interrupted after rotating previous to an older-* directory but before promoting the new backup. Use one selector for availability and restore, with a regression test covering newest-fallback selection and normal previous priority. Signed-off-by: Duang777 <[email protected]>
loopx-agent
left a comment
There was a problem hiding this comment.
Reviewer: model_agent; gpt-6.1-sol; OpenAI; runtime_reported; reasoning_effort=xhigh
动机
在 macOS 上更新 LoopX 后需要恢复上版的用户。 更新准备已把 previous 备份改名为 older-10,进程却在新备份晋升前退出;旧版报告没有恢复入口,新版可以找到原备份并完成签名校验后的恢复。 轮换中断后仍能发现并恢复最近的旧备份;正常 previous 优先级不变,未签名或损坏来源拒绝替换。 本次不证明正式发行包的完整重启链、断电持久性,也不关闭完整 S12 交付验收。
以前的代码只认识 previous。更新准备会先把它移到带时间序号的 older-*,再把新备份晋升为 previous;两步之间退出时,文件仍在,自动恢复入口却消失。修复放在原有备份 owner 中,可以减少更新中断后手动查找目录、搬回备份的返工,不需要新服务或恢复配置。
改动思路
head ef8fe686565bdef366c241164c591fed684fc215 基于 91a4672164238fc2ddc394d61901910984c5e84a。一份私有 recoverable_backup 选择规则同时供状态和恢复调用:先保持正常 previous 的优先级,缺失时在完整目录扫描中选择数值序号最大的旧备份。选择是文件状态的派生结果,未添加需要用户同步的标记或新的持久化 authority。
路径仍是 existing maintenance status → available;用户明确选择 rollback → restore → 读取所选备份版本 → restore_verified_backup。签名验证、复制、pending-version 回调与安装目标交换各自保持原 owner。Rust 在这里承担已有 macOS 原生文件效果,没有另建 Python/TypeScript 决策源。邻接 #5651 处理安装目标交换时的另一处进程死亡窗口;它目前未合入,不计作本 PR 的保护。
具体改动
依据先于 diff 读取的 apps/desktop/loopx-control-plane/README.md,固定 revision 91a4672164238fc2ddc394d61901910984c5e84a,逐项映射:Updates And Recovery / verified previous App 已实现本次轮换窗口的候选发现及签名先于交换;Updates And Recovery / Goal state unchanged 保持,整份修改未触及 Goal authority,实际探针只改自己的临时 App/data;Updates And Recovery / matching runtime after restart 是既有后续链,所选版本准确传到原 callback,但完整 Wry journal/resume/重启未在本轮执行,属于该有界轮换修复之外的发行资格,不宣布其完成。
关键代码讲解
apps/desktop/loopx-control-plane/src-tauri/src/update_backup.rs:144 recoverable_backup:保留 previous;只把可解析为 u128 的older-*和存在 App plist 的目录作为候选,再取数值最大项。9/10 的真实目录输入验证了数值顺序,无分页/显示上限或第二套缓存指针。- 同文件
:168 available:复用 selector 把候选存在投影到已有rollback_available,macOS guard 保留。候选存在不代表最终签名/版本验证成功。 - 同文件
:204 restore:版本读取和复制使用同一个被选中的备份。进入未改的restore_verified_backup后,实际ditto/codesign在 pending 回调和目标 rename 前执行,损坏来源不能凭可见按钮跳过验证。
整份 diff 仅一个文件 +52/-5,其中24行是新回归。私有 selector 同时服务两个现有 caller,消除了选择规则分叉;保留历史 on-disk previous/older/version 格式,没有新增 decoder 或迁移。相关 future-facing pass 已体现为选择规则共用;无必要扩成通用恢复框架。
对主干的风险
独立同输入 base/head 探针编译完整 immutable Rust 模块,实际执行 public available/restore,从自己临时 App 中的真实 current_exe 进入;macOS 文件系统、codesign、ditto、rename 和目标签名读回均真实。AppHandle 的路径/package lookup 及 pending-journal callback 明确是 shim,没有把它们当 Wry IPC 或实际运行时续接证明。12个 head oracle 全通过;base 有8个全状态对照失败,其中4个是旧版不能恢复的正向轮换场景,另外4个是新版故意发现、但仍安全拒绝的负向候选,不宣称8个独立旧缺陷。
还通过实际 prepare 创建和验证新临时备份,在它真实旋转旧备份的 rename 后,以相同私有 test-only exit(73) 点终止进程,再用独立调用发现/恢复;base 失败、head 成功。这是生产者路径的进程退出测试,不是正式发行 App 的强杀或断电证明。
正常 previous 优先、空目录、非数值邻居、缺少 layout 的更高序号、9/10 数值序号、以后再次轮换都检查了。未签名/篡改来源返回 backup_failed,缺少版本返回 backup_unavailable,pending 回调拒绝也不交换安装目标;备份保留,目标 marker/签名保持。仓库 Rust all-target 81 pass /2 ignored(固定 base 为80 pass /2 ignored),signed archive integration 1 ignored;Clippy、changed-file rustfmt、diff check、Python desktop12 tests+3 subtests通过。4个 generic canary 通过只作补充,它们按目录名选中 control-plane checks,不能替代实际备份验证;预存未跟踪 uv.lock 不属于这一个文件的 PR。
语义与 CI 对齐
完整 cargo fmt --all -- --check 在未改 maintenance.rs 失败;固定 base/head 同命令相同诊断 hunks,文件字节也一致。完整 semantic smoke 在未改 quota hook 的 codec_read:load_registry#1 registry I/O metadata 同样失败,固定 base/head 原因/细节相同。两者均保留为 pre_existing_unrelated,不要求这个 Rust 修复改变另一 owner,也不把检查重标为绿色;当前改变的 invariant 有独立通过证据,APPROVE 不豁免已有的本地合并/发行检查 hold。没有查询或等待 CI。新规则是原生模块私有派生选择,未创建共享状态 vocabulary;advisory 对 Rust 没有分析能力,空结果不充当语义证明。
仍未测正式 release 签名档案、实际 packaged Wry IPC/完整重启安装、断电以及总体时延/长时积累。目录越多,previous 缺失时 scan 越贵;旧目录保留原本就存在。本次签名 gate 真实通过/拒绝,但不会承诺每个 plist 候选都可恢复,也不会为了更低点击数放松验证。
我的整体评价
APPROVE,没有本次有界轮换修复的 blocking finding。长程效果 improved:已有备份可以在中断后继续恢复,减少丢失入口导致的手动返工;用户体验 improved:沿用现有状态字段和恢复操作,没有新增路径、参数、规划/确认步骤。查看了 embedded recovery 与 workspace 控件的既有消费分支,本轮没有执行完整 GUI/IPC 安装旅程,效果结论限定在已实测的原生恢复链。
效率收益是减少恢复返工;运行成本增加一次缺失 previous 时的 O(n) 目录扫描,未做计时/soak,不声称命令更快。实现规模与缺口相称、旧备份兼容保留、回滚容易;#5651 和 release-artifact/runtime restart 属于独立剩余边界,未随此批准。合并权限、现存本地检查 hold 和版本安装验收另行判断。
English verdict: APPROVE — ef8fe686565bdef366c241164c591fed684fc215 fixes interrupted backup-rotation discovery and preserves verified restore. Twelve identical native macOS filesystem/signature probes pass at head; four positive recovery cases fail at base. Cargo/Clippy/focused checks pass; unchanged base/head format and semantic failures, ignored release fixtures and explicit host/journal shims remain disclosed. No merge or full packaged restart qualification is claimed.
Goal And Delivered Outcome
Outcome basis / optional anchor: Reproduced updater crash window in the macOS backup rotation state transition.
Goal/source and gap:
prepare()rotatesprevioustoolder-*before promoting the newly copied backup, while rollback availability and restore only recognizedprevious. An interruption between those renames preserved a verified old backup but removed the operator's recovery entry point.Observable before → after, with the validation row that proves it: the regression returned no recoverable backup after the first rename before this change; it now selects the latest valid numeric
older-*, while a validpreviousstill takes priority.Issue/task and intended base: Self-contained reproduced defect; base
main.Author Declaration
Implemented against
previous→older-*crash windowrecoverable_backupinterrupted_rotation_keeps_the_latest_old_backup_recoverablepreviousbackup and ignore non-updater directory namesrecoverable_backupinterrupted_rotation_keeps_the_latest_old_backup_recoverablerestore/restore_verified_backupolder-*names and leavesrestore_verified_backup's codesign gate intact. The separate process-kill window in bundle replacement is outside this repair.Scope And Continuation
Validation
ef8fe686565bdef366c241164c591fed684fc215regression_paritypassedNoneinstead of the latestolder-*) and passes after it; it also covers malformed-name rejection and normalpreviouspriority.unitpassedcargo test --all-targets --locked: 81 passed, 2 ignored; signed-archive integration target: 1 ignored.unitpassedpython3 -m unittest discover -s tests/desktop -p 'test_*.py': 12 passed.staticpassedcargo clippy --all-targets --locked -- -D warnings.staticpassedrustfmt --check, committed/unstaged diff checks, and sensitive-path scan passed.staticpassedloopx canary premerge --from-git-diff --git-diff-base upstream/main: 4/4 selected canaries passed for one changed file.staticfailedcargo fmt --all -- --checkreports existing formatting drift in unchangedmaintenance.rs; the changed Rust file passes its direct rustfmt check.integrationnot_runSee validation disclosure guidance.
Frontend / Visual Evidence
Type of Change
LoopX Area
Technical Direction
Shared-authority RFC fixture impact
N/A
Boundary Checklist
.loopx/,.codex/goals/, and liveACTIVE_GOAL_STATE.md).none.Signed-off-bytrailer (git commit -s).