Skip to content

fix(updates): 回退先重挂页面、不可取消、落盘失败不抛 - #39

Merged
HarlonWang merged 2 commits into
mainfrom
fix/updates-rollback-robust
Oct 27, 2026
Merged

HarlonWang merged 2 commits into
mainfrom
fix/updates-rollback-robust

Conversation

@HarlonWang

@HarlonWang HarlonWang commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

背景

修复 #38 审查中 Sourcery 指出的三个问题。它们都在原有的 E2/E6 回退流程里(Updates.rollBack 和 UpdatesPage),来自 #15,不是 #38 引入的。这条路径只在线上发了坏包时才会走到,一走到就是批量用户同时受影响。

# 问题 后果
1 rollBack 先删 installed 目录、后 saveState() 两步之间进程被杀,黑名单没存上,check() 会把坏版本重新装回来,多失败一次
2 回退在页面的 LaunchedEffect 里执行,而且要等包锁 check() 在整个下载期间都持有包锁,弱网下页面会空白等十几秒;用户这时离开页面,回退就被取消
3 saveState() 失败(例如磁盘写满)时异常往外抛 异常抛进 LaunchedEffect,页面切不到内置包;按 Compose 的通常行为,在 Android 上可能导致崩溃(推断,未复现)

改动

  • UpdatesPage:先把页面切到 embedded 重新挂载,再调用 rollBack
  • rollBack:
    • 立即把 current 切回 embedded,之后新打开的页面不再拿到坏包
    • 拿锁、拉黑、落盘、上报 RolledBack 都放在 NonCancellable 里执行,不会被取消
    • saveState() 失败时发 Failed(STORAGE),不往外抛;坏包目录照样删除
    • 先落盘、再删目录
  • 同步更新 docs/updates.md §4.5

测试

:updates:testAndroidHostTest、:updates:iosSimulatorArm64Test 共 20 个用例全部通过。新增 2 个,在旧的 rollBack 上都会失败:

  • check() 持锁期间发起回退:current 立即变成 embedded;回退的协程被取消后,拉黑和 RolledBack 仍然完成
  • 把 state.json 换成目录,模拟落盘失败:不抛异常,依次上报 Failed(STORAGE) 和 RolledBack,坏包目录被删除

UpdatesPage 的调用顺序调整没有 Compose 测试;#1 的崩溃时机没法在单测里模拟,靠代码顺序保证。

审查后补充(18143d6)

  • 启动时 dropInstalled() 的存盘和删目录失败都不抛。回退存盘失败后目录已经删了,如果磁盘仍然满,下次启动会走到这里;之前会让 Updates 构造函数抛异常
  • docs/updates.md:§4.1 写明 fetch 必须有超时;§4.2 事件表补上回退存盘失败时的 Failed(STORAGE);§4.5 说明存盘失败仍删目录的取舍
  • 测试仍为 20 个,扩展了存盘失败用例:覆盖磁盘仍满时的下一次启动

🤖 Generated with Claude Code

Summary by Sourcery

Harden failed update rollback so users immediately return to the embedded page and faulty packages cannot be retried after interrupted or unsuccessful cleanup.

Bug Fixes:

  • Make update rollbacks switch pages to the embedded bundle immediately, remain safe from cancellation, and complete even when a package check is holding the lock.
  • Prevent rollback persistence failures from escaping, report them as storage failures, and still remove the faulty installed package.
  • Persist the rollback blacklist before deleting installed files to prevent failed versions from being reinstalled after a crash.

Enhancements:

  • Clarify rollback behavior, storage failure handling, and fetch timeout requirements in the updates documentation.

Documentation:

  • Update the hot-update protocol documentation with the revised rollback sequence, failure reporting, and startup behavior.

Tests:

  • Add coverage for cancellation-safe rollbacks, immediate embedded fallback, storage failures, event ordering, and cleanup.

- UpdatesPage 先切到 embedded 再调 rollBack:check() 下载期间持有包锁,
  之前页面要空白等到锁释放
- rollBack 立即把 current 切回 embedded,锁内的拉黑与落盘放进 NonCancellable,
  页面离开不再中断回退
- saveState 失败发 Failed(STORAGE) 而不是抛进 LaunchedEffect
- 先落盘再删 installed 目录,两步之间被杀不会让 check() 把坏版本装回来
- 同步 docs/updates.md §4.5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

The PR hardens E2/E6 rollback by remounting embedded content before lock acquisition, making cleanup non-cancellable, persisting the blacklist before deleting the bad package, and reporting storage failures instead of throwing; documentation and focused tests cover the new behavior.

Sequence diagram for non-cancellable update rollback

sequenceDiagram
    participant Page as UpdatesPage
    participant Updates as Updates
    participant Check as check()
    participant Lock as PackageLock
    participant FS as Filesystem
    participant Events as UpdateEvents

    Page->>Page: bundle = embedded(pkg)
    Page->>Updates: rollBack(pkg, bundle, page, failure)
    Updates->>Updates: current = embedded
    Updates->>Updates: withContext(NonCancellable)
    Updates->>Lock: withLock
    Note over Check,Lock: check() may hold the lock during download
    Updates->>Updates: failed += version
    Updates->>Updates: installed = null
    Updates->>FS: saveState()
    alt saveState() fails
        FS-->>Updates: IOException
        Updates->>Events: Failed(pkg, version, STORAGE, ...)
    end
    Updates->>FS: deleteRecursively(installed/version)
    Updates->>Events: RolledBack(pkg, version, page, ...)
    Updates-->>Page: rollback completes
Loading

File-Level Changes

Change Details Files
Make rollback switch pages immediately and complete its cleanup despite cancellation.
  • Switch the active package to embedded before waiting for the package mutex.
  • Run blacklist, state persistence, package deletion, and rollback reporting in NonCancellable while preserving lock serialization.
  • Persist rollback state before deleting the installed version.
updates/src/commonMain/kotlin/app/tinyui/updates/Updates.kt
updates/src/commonMain/kotlin/app/tinyui/updates/UpdatesPage.kt
Handle rollback persistence failures without propagating exceptions.
  • Convert state-save IO failures into Failed(STORAGE) events.
  • Continue deleting the bad installed package and emit RolledBack even when persistence fails.
updates/src/commonMain/kotlin/app/tinyui/updates/Updates.kt
Document and test the revised rollback guarantees.
  • Update the rollback protocol documentation with ordering, cancellation, and storage-failure semantics.
  • Add coverage for rollback during an in-progress check and failed state persistence.
docs/updates.md
updates/src/commonTest/kotlin/app/tinyui/updates/UpdatesTest.kt

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai

coderabbitai Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 338f08b4-c941-4273-8136-fb1e96575827
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codeseerbot codeseerbot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CodeSeer 审查

结论:批准(附 1 条建议)

摘要

本次改动将页面重挂提前,并把回退的落盘、删除和事件上报放入 NonCancellable,同时补充了锁竞争和落盘失败的测试。包锁已被占用后回退不再会因页面协程取消而中断,落盘失败也能上报并继续删除坏包。主要残余风险是回退任务仍由页面生命周期绑定的 LaunchedEffect 创建,在协程实际开始前页面离开时,整个回退仍可能完全不执行。

整个 PR 至 62b5aa2 · gpt-5.6-terra

if ((failure.kind == "E2" || failure.kind == "E6") && page.bundle !== updates.embedded(pkg)) {
LaunchedEffect(failure) {
updates.rollBack(pkg, page.bundle, page.module.name, failure)
// remount first: the rollback can wait for a check() downloading under the package lock

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[建议] rollBack 虽然在进入函数后会切到 NonCancellable,但它仍由页面生命周期绑定的 LaunchedEffect 启动;如果页面在该 effect 的协程实际开始前离开组合,effect 会被取消,bundle = ... 和 rollBack(...) 都不会执行。这样仍会保留坏包为 current,也不会拉黑或删除它,未完全满足“页面离开不取消回退”的目标。应将回退调度到生命周期独立、由 Updates 或宿主长期持有的 scope,或在能保证任务已提交到该 scope 后再允许页面 effect 结束。

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

不改。effect 从进入组合到协程开始执行只隔一次调度,通常不到一帧,用户来不及在这期间离开页面。即使发生,后果也能自己恢复:坏版本没被拉黑,下次打开页面会再报 E2/E6、再走一次回退。要彻底消除这个窗口,就得让 Updates 持有一个长期存在的协程作用域,还要定义它的生命周期和取消语义,代价超过收益。

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've found 3 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="updates/src/commonMain/kotlin/app/tinyui/updates/Updates.kt" line_range="135" />
<code_context>
+                } catch (e: IOException) {
+                    onEvent(UpdateEvent.Failed(pkg, version, FailStage.STORAGE, "rollback not saved: ${e.message ?: e}"))
+                }
+                if (drop) runCatching { fs.deleteRecursively(p.root / INSTALLED / version) }
             }
-            p.saveState()
</code_context>
<issue_to_address>
**Rollback leaves stale installed state**

When rollback state saving fails and the app later restarts, `rollBack` still deletes the installed directory while the previous state file can still name that version without its failed-version blacklist. With storage still unavailable, `selectAtStartup()` calls `dropInstalled()`, whose `saveState()` failure aborts construction; if storage recovers, startup clears the stale pointer without blacklisting the version, so `check()` can install the same bad version again.

Keep the installed directory until the rollback state, including the failed-version blacklist, is durably saved, and delete it only after that save succeeds.
</issue_to_address>

### Comment 2
<location path="updates/src/commonMain/kotlin/app/tinyui/updates/Updates.kt" line_range="125" />
<code_context>
     internal suspend fun rollBack(pkg: String, bundle: Bundle, page: String, failure: PageFailure) {
         val p = pkg(pkg)
         val version = bundle.manifest.version
-        p.lock.withLock {
-            if (version !in p.failed) p.failed += version
-            p.current = p.embedded
</code_context>
<issue_to_address>
**Rollback never reaches blacklist**

When a host-provided fetch stays suspended indefinitely while `check()` holds the package lock, `p.lock.withLock` waits for the active check, and `NonCancellable` keeps that wait alive even after its caller is gone. If the host-provided `fetch` never completes, the package stays embedded only in this process; the failed version is never persisted and `RolledBack` is never emitted.

Require or enforce a bounded check/fetch duration and define how rollback completes if that bound is exceeded.
</issue_to_address>

### Comment 3
<location path="updates/src/commonMain/kotlin/app/tinyui/updates/Updates.kt" line_range="133" />
<code_context>
+                try {
+                    p.saveState()
+                } catch (e: IOException) {
+                    onEvent(UpdateEvent.Failed(pkg, version, FailStage.STORAGE, "rollback not saved: ${e.message ?: e}"))
+                }
+                if (drop) runCatching { fs.deleteRecursively(p.root / INSTALLED / version) }
</code_context>
<issue_to_address>
**Rollback failure event is undocumented**

`rollBack` emits `Failed(STORAGE)`, but the event table describes `Failed` as occurring during `check()` only. Hosts following that contract can misclassify or omit rollback storage failures from their event handling and telemetry.

Update the event contract to include rollback persistence failures as a source of `Failed(STORAGE)`.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 2 findings to address first, and a rollback can persist a version blacklist and clear the installed-version marker, so those effects remain after a revert; a failed save is reported while the process stays on the embedded page. The impact is bounded to that package version and can be repaired by installing a newer version or restoring package state.

Blocking findings: updates/src/commonMain/kotlin/app/tinyui/updates/Updates.kt:135, updates/src/commonMain/kotlin/app/tinyui/updates/Updates.kt:125


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

- dropInstalled 的落盘与删目录失败不抛:回退落盘失败后目录已删,磁盘仍满时
  下次启动会走到这里,之前会让 Updates 构造抛出
- docs §4.1 写明 fetch 必须有超时;§4.2 事件表补回退落盘失败的 Failed(STORAGE);
  §4.5 说明落盘失败仍删目录的取舍

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@codeseerbot codeseerbot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CodeSeer 审查

结论:批准(附 1 条建议)

上轮意见:1 条,已处理 0 条,待处理 1 条

  • updates/src/commonMain/kotlin/app/tinyui/updates/UpdatesPage.kt:63 [建议] rollBack 虽然在进入函数后会切到 NonCancellable,但它仍由页面生命周期绑定的 LaunchedEffect …

摘要

本次改动将回退时的当前包切换前置,并用 NonCancellable 保证等待包锁后的黑名单落盘和回退事件不会因页面销毁而中断。回退状态改为先持久化后清理安装目录,且对回退和启动阶段的状态写入失败进行了兜底;测试覆盖了检查持锁和状态文件写入失败的场景。未发现本次增量引入的可确认问题。

增量 62b5aa2..18143d6 · gpt-5.6-terra

@HarlonWang
HarlonWang merged commit 2de7334 into main Oct 27, 2026
5 checks passed
@HarlonWang
HarlonWang deleted the fix/updates-rollback-robust branch October 27, 2026 02:02
@HarlonWang HarlonWang mentioned this pull request Oct 27, 2026
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.

1 participant