Skip to content

fix(desktop): recover interrupted backup rotations - #5647

Merged
huangruiteng merged 1 commit into
loopx-project:mainfrom
Duang777:codex/fix-desktop-backup-rotation-20261005
Oct 5, 2026
Merged

huangruiteng merged 1 commit into
loopx-project:mainfrom
Duang777:codex/fix-desktop-backup-rotation-20261005

Conversation

@Duang777

@Duang777 Duang777 commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator

Goal And Delivered Outcome

  • Outcome basis / optional anchor: Reproduced updater crash window in the macOS backup rotation state transition.

  • Goal/source and gap: prepare() rotates previous to older-* before promoting the newly copied backup, while rollback availability and restore only recognized previous. 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 valid previous still takes priority.

  • Issue/task and intended base: Self-contained reproduced defect; base main.

Author Declaration

  • Written by: model_agent (OpenAI Codex)

Implemented against

  • Specification and revision: No written specification; the request in this PR is the basis.
  • Criteria:
Criterion (spec clause) Disposition Symbol / path Test or command
Preserve rollback availability across the previous → older-* crash window implemented recoverable_backup interrupted_rotation_keeps_the_latest_old_backup_recoverable
Prefer the normal previous backup and ignore non-updater directory names implemented recoverable_backup interrupted_rotation_keeps_the_latest_old_backup_recoverable
Use the selected backup consistently for version read and signature-verified restore implemented restore / restore_verified_backup Rust all-target tests and Clippy
  • Self-check before submission: Read the backup lifecycle and both desktop bundle rename paths; verified the fallback accepts only numeric updater-owned older-* names and leaves restore_verified_backup's codesign gate intact. The separate process-kill window in bundle replacement is outside this repair.

Scope And Continuation

  • Completed scope and remaining work: Complete within this scope. Interrupted backup rotation remains recoverable without changing backup creation or restore verification.
  • Slice boundary / successor: N/A; broader crash-durability work for bundle replacement is independent and is not required for this fix.

Validation

  • Tested revision: ef8fe686565bdef366c241164c591fed684fc215
  • Run state: finished
  • Input classes: synthetic
Check kind Result Public-safe evidence / limitation
regression_parity passed New interruption regression failed before the implementation (None instead of the latest older-*) and passes after it; it also covers malformed-name rejection and normal previous priority.
unit passed cargo test --all-targets --locked: 81 passed, 2 ignored; signed-archive integration target: 1 ignored.
unit passed python3 -m unittest discover -s tests/desktop -p 'test_*.py': 12 passed.
static passed cargo clippy --all-targets --locked -- -D warnings.
static passed Changed-file rustfmt --check, committed/unstaged diff checks, and sensitive-path scan passed.
static passed loopx canary premerge --from-git-diff --git-diff-base upstream/main: 4/4 selected canaries passed for one changed file.
static failed Repository-wide cargo fmt --all -- --check reports existing formatting drift in unchanged maintenance.rs; the changed Rust file passes its direct rustfmt check.
integration not_run Real backup-copy and signed-archive cases require signed macOS release artifacts; existing tests remain ignored without those inputs.
  • Coverage and gaps: Synthetic filesystem coverage exercises both backup-selection branches and the interruption state. Existing restore tests cover swap rollback and damaged targets; the all-target suite confirms the shared selector does not bypass those paths. Real codesign artifact checks remain delegated to release CI because no signed fixture was supplied.

See validation disclosure guidance.

Frontend / Visual Evidence

  • UI impact: none
  • Before: N/A
  • After: N/A
  • States and viewports shown: N/A
  • Source data: none
  • Attention review: N/A; no visual surface changed.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Refactoring (no functional changes)
  • Documentation update
  • Test update

LoopX Area

  • Control plane (goals, todos, quota, scheduler, registry, runtime)
  • Benchmark boundary (adapters, runners, verifiers, scoring, evidence)
  • Capability or extension (providers, adapters, skills)
  • Public docs or presentation surface (README, protocols, dashboard)
  • Build, packaging, installer, or CI
  • Host or runtime integration

Technical Direction

  • Direction / acceptance reference, when applicable: N/A

Shared-authority RFC fixture impact

N/A

Boundary Checklist

  • Neither the diff nor this PR body/comments/attachments disclose private state, credentials, raw traces or verifier output, internal links, or local machine paths (including .loopx/, .codex/goals/, and live ACTIVE_GOAL_STATE.md).
  • I did not duplicate maintainer-owned benchmark work unless a maintainer split out a public issue for it.
  • I kept the change scoped to the linked issue/task.
  • I completed the visual evidence section for UI changes, or marked UI impact none.
  • Every commit includes a DCO Signed-off-by trailer (git commit -s).

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 loopx-agent left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/重启未在本轮执行,属于该有界轮换修复之外的发行资格,不宣布其完成。

关键代码讲解

  1. apps/desktop/loopx-control-plane/src-tauri/src/update_backup.rs:144 recoverable_backup:保留 previous;只把可解析为 u128 的 older-* 和存在 App plist 的目录作为候选,再取数值最大项。9/10 的真实目录输入验证了数值顺序,无分页/显示上限或第二套缓存指针。
  2. 同文件 :168 available:复用 selector 把候选存在投影到已有 rollback_available,macOS guard 保留。候选存在不代表最终签名/版本验证成功。
  3. 同文件 :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.

@huangruiteng
huangruiteng merged commit 35f4d25 into loopx-project:main Oct 5, 2026
25 of 36 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants