fix(update): self-heal stuck pending updates and add discard action

Problem
In-app updates could permanently fail after a probationary transaction
never received startup health confirmation. Version strings with and
without a leading "v" skipped health capture, so AwaitingHealth blocked
every later update while the product was already usable.

Root cause
Health identity capture and MarkUpdateHealthy compared versions with
exact string equality. Missed post-DOM health commits left durable
pending-update.json markers with no automatic retirement path and no
user-facing abandon action.

Fix
- Normalize version comparison for pending-update health paths.
- Auto-commit stale probationary updates after 24h when install evidence
  still holds.
- Re-attempt health commit before install; expose AbandonPendingUpdate
  with banner/settings "Discard previous update".
- Document in-app update recovery steps in RECOVERY docs.

Verification
- go test ./internal/repair/
- go test . -run TestCapturePendingUpdateHealth (desktop)
- tsx desktop/frontend/src/__tests__/updater-shared-state.test.tsx
This commit is contained in:
SivanCola committed 2026-08-07 16:53:51 +08:00
1 parent 0c0429a8fa
commit 7e1e5a9d07
15 files changed
+462 -13

No files matched your search

@@ -70,6 +70,7 @@ ok(document.getElementById("banner-status")?.textContent === "idle", "banner sta
ok(document.getElementById("settings-status")?.textContent === "idle", "settings starts idle");
ok(classifyUpdateError("prepare update: a pending update already exists") === "recovery", "pending update errors require recovery fallback");
ok(classifyUpdateError("prepare update: recover existing handoff backup: operation not permitted") === "recovery", "macOS backup permission errors require recovery fallback");
ok(classifyUpdateError("update recovery: the previous update is still completing its startup health check; wait briefly and try again, or discard the previous update") === "recovery", "awaiting-health errors require recovery fallback");
ok(classifyUpdateError("update: manual update required") === "manual", "manual-only errors prefer the official download");
ok(classifyUpdateError("connection reset by peer") === "retryable", "transient errors remain retryable");
@@ -7189,7 +7189,7 @@ function UpdatesSection({
applySettings: (fn: () => Promise<void>) => Promise<boolean>;
}) {
const t = useT();
const { status, check, apply: applyUpdate, openDownload } = useUpdater();
const { status, check, apply: applyUpdate, openDownload, abandonPending } = useUpdater();
const [version, setVersion] = useState("");
useEffect(() => {
app.Version().then(setVersion).catch(() => {});
@@ -7332,6 +7332,16 @@ function UpdatesSection({
)}
</div>
<span className="banner__spacer" />
{status.disposition === "recovery" && (
<button
className="btn btn--small"
type="button"
disabled={settingsBusy || updaterBusy}
onClick={() => void abandonPending()}
>
{t("updater.discardPrevious")}
</button>
)}
{downloadIsPrimary && (
<button className="btn btn--primary btn--small" type="button" onClick={openDownload}>
{t("updater.officialDownload")}
@@ -18,7 +18,7 @@ export function UpdateBanner({
onShowReleaseNotes?: (version: string) => void;
}) {
const t = useT();
const { status, check, apply, openDownload, reset } = useUpdater();
const { status, check, apply, openDownload, abandonPending, reset } = useUpdater();
const [dismissed, setDismissed] = useState<string | null>(null);
useEffect(() => {
@@ -91,6 +91,11 @@ export function UpdateBanner({
{failedMessage}
</span>
<span className="banner__spacer" />
{status.disposition === "recovery" && (
<button className="btn btn--small" onClick={() => void abandonPending()}>
{t("updater.discardPrevious")}
</button>
)}
{downloadFirst && (
<button className="btn btn--small btn--primary" onClick={openDownload}>
{t("updater.officialDownload")}
+3
View File
@@ -514,6 +514,8 @@ export interface AppBindings {
CheckUpdate(channel: string): Promise<UpdateInfo | null>;
/** v1.20+ single-action update: download, verify, install, relaunch. */
ApplyUpdateRequest(channel: string, expectedVersion: string, requestId: string): Promise<void>;
/** Discard a stuck previous update transaction so the next install can proceed. */
AbandonPendingUpdate?(): Promise<void>;
OpenDownloadPage(): Promise<void>;
OpenUserConfigPath?(): Promise<void>;
ReloadUserConfig?(): Promise<{ configWarnings?: string[]; configPath?: string } | null>;
@@ -4668,6 +4670,7 @@ function makeMockApp(): AppBindings {
await delay(300);
emitUpdater({ requestId, version: expectedVersion, channel: selectedChannel, phase: "relaunching", received: 0, total: 0 });
},
async AbandonPendingUpdate() {},
async OpenDownloadPage() {
if (typeof window !== "undefined") {
window.open("https://reasonix.io/?download=desktop#start", "_blank", "noopener");
+22 -2
View File
@@ -28,6 +28,8 @@ export interface Updater {
/** Single-action update: download + verify + install + relaunch. */
apply: (info: UpdateInfo) => void;
openDownload: () => void;
/** Discard a stuck previous update transaction so the next install can proceed. */
abandonPending: () => Promise<void>;
reset: () => void;
}
@@ -37,7 +39,7 @@ function errMsg(e: unknown): string {
export function classifyUpdateError(message: string): UpdateErrorDisposition {
const low = message.toLowerCase();
if (/pending update already exists|could not safely finish the previous update|handoff backup/.test(low)) {
if (/pending update already exists|could not safely finish the previous update|handoff backup|awaiting startup health|discard the previous update|previous update is still completing/.test(low)) {
return "recovery";
}
if (/authorization failed|manual update required|pkexec|sudo apt install/.test(low)) {
@@ -247,6 +249,24 @@ function useUpdaterInternal(): Updater {
void app.OpenDownloadPage();
}, []);
const abandonPending = useCallback(async () => {
const active = operationRef.current;
if (isBusyOperation(active.kind)) return;
const operation = beginOperation(active.channel || "stable", "checking");
try {
if (typeof app.AbandonPendingUpdate === "function") {
await app.AbandonPendingUpdate();
}
if (!isCurrentOperation(operation)) return;
completeOperation(operation);
setStatus({ kind: "idle" });
} catch (e) {
if (!isCurrentOperation(operation)) return;
completeOperation(operation);
setStatus(updateError(errMsg(e)));
}
}, [beginOperation, completeOperation, isCurrentOperation]);
const reset = useCallback(() => {
const epoch = operationRef.current.epoch + 1;
operationRef.current = {
@@ -259,7 +279,7 @@ function useUpdaterInternal(): Updater {
setStatus({ kind: "idle" });
}, []);
return { status, check, apply, openDownload, reset };
return { status, check, apply, openDownload, abandonPending, reset };
}
export function UpdaterProvider({ children }: { children: ReactNode }) {
+2 -1
View File
@@ -2974,11 +2974,12 @@ export const en = {
"updater.done": "Update complete — restarting…",
"updater.failed": "Update failed: {msg}",
"updater.recoveryBlocked": "The previous update has not finished.",
"updater.recoveryHint": "Restart Reasonix and retry. On macOS, allow Reasonix in System Settings > Privacy & Security > App Management. If recovery is still blocked, download the latest signed installer and install it over the current version without uninstalling first.",
"updater.recoveryHint": "Try “Discard previous update”, then retry. Restart Reasonix if the button is unavailable. On macOS, allow Reasonix in System Settings > Privacy & Security > App Management. If recovery is still blocked, download the latest signed installer and install it over the current version without uninstalling first.",
"updater.manualUpdateRequired": "Automatic installation is unavailable.",
"updater.manualFallbackHint": "Download the latest signed installer from the official site and install it over the current version.",
"updater.errorDetails": "Details: {msg}",
"updater.retry": "Retry",
"updater.discardPrevious": "Discard previous update",
"updater.dismiss": "Later",
"changelog.title": "Release notes",
"changelog.subtitle": "A product-focused history of new capabilities, improvements, fixes, and upgrade guidance.",
+2 -1
View File
@@ -2086,11 +2086,12 @@ export const zhTW: Record<DictKey, string> = {
"updater.done": "更新完成,正在重新啟動…",
"updater.failed": "更新失敗:{msg}",
"updater.recoveryBlocked": "上次更新尚未完成。",
"updater.recoveryHint": "請重新啟動 Reasonix 後重試。macOS 使用者可在「系統設定 > 隱私權與安全性 > App 管理」中允許 Reasonix;若仍無法恢復,請從官網下載最新簽署安裝包直接覆蓋安裝,無需先解除安裝。",
"updater.recoveryHint": "可先點「放棄上次更新」再重試;若按鈕不可用請重新啟動 Reasonix。macOS 使用者可在「系統設定 > 隱私權與安全性 > App 管理」中允許 Reasonix;若仍無法恢復,請從官網下載最新簽署安裝包直接覆蓋安裝,無需先解除安裝。",
"updater.manualUpdateRequired": "目前環境無法自動安裝更新。",
"updater.manualFallbackHint": "請從官網下載最新簽署安裝包,直接覆蓋安裝目前版本。",
"updater.errorDetails": "詳細資訊:{msg}",
"updater.retry": "重試",
"updater.discardPrevious": "放棄上次更新",
"updater.dismiss": "稍後",
"changelog.title": "更新日誌",
"changelog.subtitle": "依產品能力整理新功能、改進、修復與升級提醒,不再只是提交記錄。",
+2 -1
View File
@@ -2977,11 +2977,12 @@ export const zh: Record<DictKey, string> = {
"updater.done": "更新完成,正在重启…",
"updater.failed": "更新失败:{msg}",
"updater.recoveryBlocked": "上次更新尚未完成。",
"updater.recoveryHint": "请重新启动 Reasonix 后重试。macOS 用户可在“系统设置 > 隐私与安全性 > App 管理”中允许 Reasonix;若仍无法恢复,请从官网下载最新签名安装包直接覆盖安装,无需先卸载。",
"updater.recoveryHint": "可先点「放弃上次更新」再重试;若按钮不可用请重启 Reasonix。macOS 用户可在“系统设置 > 隐私与安全性 > App 管理”中允许 Reasonix;若仍无法恢复,请从官网下载最新签名安装包直接覆盖安装,无需先卸载。",
"updater.manualUpdateRequired": "当前环境无法自动安装更新。",
"updater.manualFallbackHint": "请从官网下载最新签名安装包,直接覆盖安装当前版本。",
"updater.errorDetails": "详细信息:{msg}",
"updater.retry": "重试",
"updater.discardPrevious": "放弃上次更新",
"updater.dismiss": "稍后",
"changelog.title": "更新日志",
"changelog.subtitle": "按产品能力整理新功能、改进、修复与升级提醒,不再只是提交记录。",
+23
View File
@@ -139,6 +139,29 @@ func TestCaptureAndCommitPendingUpdateHealthUsesExactStartupIdentity(t *testing.
}
}
func TestCapturePendingUpdateHealthAcceptsVersionPrefixMismatch(t *testing.T) {
originalRead := readPendingUpdateForHealth
originalVersion := version
t.Cleanup(func() {
readPendingUpdateForHealth = originalRead
version = originalVersion
})
version = "1.21.0"
readPendingUpdateForHealth = func() (*repair.UpdateTransaction, error) {
return &repair.UpdateTransaction{
ToVersion: "v1.21.0",
CreatedAt: "2026-08-07T00:00:00Z",
TargetKind: "file",
}, nil
}
app := &App{}
capturePendingUpdateHealthIdentity(app)
if app.healthyUpdateCreatedAt == "" || app.healthyUpdateTransactionID == "" {
t.Fatalf("expected health identity for v-prefix mismatch, got createdAt=%q id=%q",
app.healthyUpdateCreatedAt, app.healthyUpdateTransactionID)
}
}
func TestCapturePendingUpdateHealthRejectsDifferentTargetVersion(t *testing.T) {
originalRead := readPendingUpdateForHealth
t.Cleanup(func() { readPendingUpdateForHealth = originalRead })
+9 -1
View File
@@ -1293,13 +1293,21 @@ func capturePendingUpdateHealthIdentity(app *App) {
return
}
tx, err := readPendingUpdateForHealth()
if err != nil || tx == nil || strings.TrimSpace(tx.ToVersion) != strings.TrimSpace(version) {
if err != nil || tx == nil || !repair.UpdateVersionsEqual(tx.ToVersion, version) {
return
}
app.healthyUpdateCreatedAt = tx.CreatedAt
app.healthyUpdateTransactionID = repair.UpdateTransactionID(tx)
}
// refreshPendingUpdateHealthIdentity re-reads the current probationary
// transaction so a user-initiated update can commit health even when the
// process started without a matching identity (for example a historical
// version-prefix mismatch).
func refreshPendingUpdateHealthIdentity(app *App) {
capturePendingUpdateHealthIdentity(app)
}
// updateSiblingArtifacts lists the packaged binaries an update replaces beside
// the main executable, so PrepareFileUpdate can snapshot the complete release
// unit. Paths that do not exist on disk are skipped by the backup.
+47 -1
View File
@@ -278,10 +278,33 @@ func (a *App) reconcilePendingUpdateForRequest(requestID string, meta *cachedUpd
} else if archived {
slog.Info("desktop: archived superseded update before install")
}
// Visible UI at the pending target version is health evidence. Commit a
// probationary transaction before reconcile so a missed post-DOM health
// task does not block the next update forever.
refreshPendingUpdateHealthIdentity(a)
if err := a.commitPendingUpdateHealth(); err != nil {
slog.Debug("desktop: commit healthy update before install", "err", err)
} else if committed, err := repair.CommitProbationaryPendingUpdate(version); err != nil {
slog.Debug("desktop: probationary update commit before install", "err", err)
} else if committed {
slog.Info("desktop: committed probationary update before install")
}
}
if _, err := reconcilePendingUpdateForInstall(version); err != nil {
if errors.Is(err, repair.ErrPendingUpdateAwaitingHealth) {
err = fmt.Errorf("update recovery: the previous update is still completing its startup health check; wait briefly and try again")
// One more heal attempt after refresh: version-prefix mismatches used
// to leave AwaitingHealth permanently even though the product works.
refreshPendingUpdateHealthIdentity(a)
if commitErr := a.commitPendingUpdateHealth(); commitErr == nil {
if _, retryErr := reconcilePendingUpdateForInstall(version); retryErr == nil {
return nil
} else {
err = retryErr
}
}
}
if errors.Is(err, repair.ErrPendingUpdateAwaitingHealth) {
err = fmt.Errorf("update recovery: the previous update is still completing its startup health check; wait briefly and try again, or discard the previous update")
} else {
err = fmt.Errorf("update recovery: could not safely finish the previous update: %w", err)
}
@@ -290,6 +313,29 @@ func (a *App) reconcilePendingUpdateForRequest(requestID string, meta *cachedUpd
return nil
}
// AbandonPendingUpdate is a user-facing recovery action for stuck in-app
// updates. It commits a still-running probationary target when possible,
// otherwise cancels or rolls back the unfinished transaction, and as a last
// resort force-retires a probationary marker that already owns the install.
func (a *App) AbandonPendingUpdate() error {
if !pendingUpdateExistsForInstall() {
return nil
}
refreshPendingUpdateHealthIdentity(a)
if err := a.commitPendingUpdateHealth(); err != nil {
slog.Debug("desktop: commit healthy update during abandon", "err", err)
}
if archived, err := archiveSupersededPendingUpdateForInstall(); err != nil {
slog.Debug("desktop: archive superseded update during abandon", "err", err)
} else if archived {
return nil
}
if _, err := repair.AbandonPendingUpdate(version); err != nil {
return fmt.Errorf("could not discard the previous update: %w", err)
}
return nil
}
func (a *App) installDebUpdate(requestID string, meta *cachedUpdate) error {
// authorizing = Polkit password dialog. The helper streams
// REASONIX_UPDATE_PHASE=installing on stderr after validation and before
+18
View File
@@ -53,6 +53,24 @@ If an older client is stuck on a pending update or Safe Mode loop:
Do not manually delete `pending-update.json`, locks, or AppData as the recovery
procedure.
## In-app update stuck
If Settings → Updates (or the top banner) reports that the previous update has
not finished (`pending update already exists`, `awaiting startup health`, or
`handoff backup` errors):
1. Click **Discard previous update** in the banner or Settings, then **Retry**.
2. If that button is missing or fails, quit Reasonix fully and start it once so
startup can commit or retire the probationary transaction, then retry the
in-app update.
3. If in-app update still fails, download the latest signed installer from the
official download page and install it **over** the current copy without
uninstalling first.
4. On macOS, also allow Reasonix under System Settings → Privacy & Security →
App Management when the dialog appears; a leftover
`Reasonix.app.reasonix-update-backup` that TCC will not let the app remove
may still require the official installer path.
If the Windows installer reports `Reasonix layout activation failed`, expand
the installer details and copy the lines under `Reasonix layout activator
output:`. Current installers preserve the activator's concrete error instead of
+11
View File
@@ -47,6 +47,17 @@ InstallRoot/
正式恢复路径**不要求**手工删除 `pending-update.json`、锁文件或 AppData。
## 应用内更新卡住
若「设置 → 更新」或顶部横幅提示上次更新尚未完成(含 `pending update already exists`、
`awaiting startup health`、`handoff backup` 等错误):
1. 在横幅或设置里点 **「放弃上次更新」**,再点 **「重试」**。
2. 若没有该按钮或操作失败:完全退出 Reasonix 后重新启动一次(让启动路径提交或清理试用事务),再试应用内更新。
3. 若仍失败:从官方下载页获取最新签名安装包,**直接覆盖安装**当前版本,不要先卸载。
4. macOS 还需在「系统设置 → 隐私与安全性 → App 管理」中允许 Reasonix;若
`Reasonix.app.reasonix-update-backup` 因 TCC 无法删除,请改走官方安装包覆盖路径。
如果 Windows 安装器显示 `Reasonix layout activation failed`,请展开安装详情并复制
`Reasonix layout activator output:` 下方的内容。当前安装器会保留 activator 的具体错误,
不再只显示 exit code 1。
+212 -4
View File
@@ -107,22 +107,84 @@ var ErrPendingUpdateAwaitingHealth = errors.New("previous update is awaiting sta
var errPendingUpdateForeignInstall = errors.New("pending update belongs to a different installation")
// pendingUpdateHealthStaleAfter is how long a probationary update may wait for
// startup health confirmation before reconciliation treats a still-running
// target as healthy. Without this bound, a missed MarkUpdateHealthy call
// permanently blocks every later in-app update while the product is already
// usable. Tests may override the duration.
var pendingUpdateHealthStaleAfter = 24 * time.Hour
// PendingUpdateReconcileResult describes the safe transition performed before
// startup or a new install. Cleared means a pre-publish transaction was
// cancelled while every original target still matched its prepared state.
// RolledBack means replacement had started and the verified previous release
// unit was restored.
// unit was restored. Healthy means a probationary target was committed after
// install evidence (and, for the automatic path, age) proved the running
// release is already usable.
type PendingUpdateReconcileResult struct {
Pending bool `json:"pending"`
Cleared bool `json:"cleared,omitempty"`
RolledBack bool `json:"rolledBack,omitempty"`
MixedInstall bool `json:"mixedInstall,omitempty"`
AwaitingHealth bool `json:"awaitingHealth,omitempty"`
Healthy bool `json:"healthy,omitempty"`
FromVersion string `json:"fromVersion,omitempty"`
ToVersion string `json:"toVersion,omitempty"`
TargetPath string `json:"targetPath,omitempty"`
}
// UpdateVersionsEqual reports whether two release version strings name the same
// release. Desktop build injection and pending-update records historically
// disagreed on a leading "v", which silently skipped health commits and left
// AwaitingHealth transactions stranded forever.
func UpdateVersionsEqual(a, b string) bool {
a = strings.TrimSpace(a)
b = strings.TrimSpace(b)
if a == "" || b == "" {
return false
}
if a == b {
return true
}
return normalizeUpdateVersion(a) == normalizeUpdateVersion(b)
}
func normalizeUpdateVersion(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
if !strings.HasPrefix(v, "v") && !strings.HasPrefix(v, "V") {
return "v" + v
}
return "v" + strings.TrimPrefix(strings.TrimPrefix(v, "v"), "V")
}
// pendingUpdateHealthIsStaleOverride lets tests force the stale decision without
// rewriting CreatedAt (which is part of the transaction identity and would
// break install-evidence binding).
var pendingUpdateHealthIsStaleOverride func(*UpdateTransaction) bool
func pendingUpdateHealthIsStale(tx *UpdateTransaction) bool {
if tx == nil {
return false
}
if pendingUpdateHealthIsStaleOverride != nil {
return pendingUpdateHealthIsStaleOverride(tx)
}
if pendingUpdateHealthStaleAfter <= 0 {
return false
}
created, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(tx.CreatedAt))
if err != nil {
created, err = time.Parse(time.RFC3339, strings.TrimSpace(tx.CreatedAt))
}
if err != nil {
return false
}
return time.Since(created) >= pendingUpdateHealthStaleAfter
}
// UpdateTransactionID returns a stable, opaque identity for the complete
// transaction. Platform handoff processes use it so copied scalar fields such
// as version and creation time cannot authorize a rewritten pending update.
@@ -2004,8 +2066,23 @@ func ReconcilePendingUpdate(runningVersion string) (PendingUpdateReconcileResult
ToVersion: tx.ToVersion,
TargetPath: tx.TargetPath,
}
if strings.TrimSpace(runningVersion) == strings.TrimSpace(tx.ToVersion) &&
if UpdateVersionsEqual(runningVersion, tx.ToVersion) &&
pendingUpdateInstalledForHealth(tx) {
// A still-running probationary target whose health commit never fired
// (version-prefix mismatch, aborted post-DOM task, etc.) used to block
// every later update forever. After the stale window, commit the
// installed release so the product can self-heal while remaining in use.
if pendingUpdateHealthIsStale(tx) {
if healErr := MarkUpdateHealthy(runningVersion); healErr == nil && !PendingUpdateExists() {
result.Pending = false
result.Healthy = true
result.Cleared = true
return result, nil
} else if healErr != nil {
slog.Warn("repair: stale probationary update could not be committed automatically",
"toVersion", tx.ToVersion, "err", healErr)
}
}
result.AwaitingHealth = true
return result, ErrPendingUpdateAwaitingHealth
}
@@ -2052,6 +2129,137 @@ func ReconcilePendingUpdate(runningVersion string) (PendingUpdateReconcileResult
return result, nil
}
// CommitProbationaryPendingUpdate commits a still-running probationary update
// when the installed release unit matches the pending target. It is the shared
// heal path used by startup health, user-initiated updates, and explicit
// abandon. Returns true when the pending transaction no longer exists.
func CommitProbationaryPendingUpdate(runningVersion string) (bool, error) {
if strings.TrimSpace(runningVersion) == "" {
return false, nil
}
tx, err := ReadPendingUpdate()
if err != nil {
if os.IsNotExist(err) {
return true, nil
}
return false, err
}
if !UpdateVersionsEqual(runningVersion, tx.ToVersion) || !pendingUpdateInstalledForHealth(tx) {
return false, nil
}
if err := MarkUpdateHealthy(runningVersion); err != nil {
return false, err
}
return !PendingUpdateExists(), nil
}
// AbandonPendingUpdate is the user-initiated recovery path for a stuck
// transaction. Prefer committing a still-running probationary target; otherwise
// cancel or roll back through the normal reconcile path. Returns a result
// describing the transition performed.
func AbandonPendingUpdate(runningVersion string) (PendingUpdateReconcileResult, error) {
committed, err := CommitProbationaryPendingUpdate(runningVersion)
if err != nil {
return PendingUpdateReconcileResult{Pending: true}, fmt.Errorf("abandon pending update: %w", err)
}
if committed {
return PendingUpdateReconcileResult{Cleared: true, Healthy: true}, nil
}
result, reconcileErr := ReconcilePendingUpdate(runningVersion)
if reconcileErr == nil {
return result, nil
}
// AwaitingHealth after a failed commit is still stuck. When install
// evidence holds and the user explicitly abandons, retire the marker and
// best-effort remove backups so later updates can proceed while the
// running product stays in place.
if errors.Is(reconcileErr, ErrPendingUpdateAwaitingHealth) {
if retired, retireErr := forceRetireProbationaryPendingUpdate(runningVersion); retireErr != nil {
return result, fmt.Errorf("abandon pending update: %w", retireErr)
} else if retired {
result.Pending = false
result.AwaitingHealth = false
result.Healthy = true
result.Cleared = true
return result, nil
}
}
return result, reconcileErr
}
// forceRetireProbationaryPendingUpdate removes a probationary transaction that
// already owns the running install when MarkUpdateHealthy cannot finish (for
// example a drifted backup digest). It never rolls the live release unit back.
func forceRetireProbationaryPendingUpdate(runningVersion string) (bool, error) {
tx, err := ReadPendingUpdate()
if err != nil {
if os.IsNotExist(err) {
return true, nil
}
return false, err
}
if !UpdateVersionsEqual(runningVersion, tx.ToVersion) || !pendingUpdateInstalledForHealth(tx) {
return false, nil
}
unlock, err := acquirePendingUpdateLock()
if err != nil {
return false, fmt.Errorf("force retire probationary update: lock pending transaction: %w", err)
}
defer unlock()
current, err := ReadPendingUpdate()
if err != nil {
if os.IsNotExist(err) {
return true, nil
}
return false, err
}
if UpdateTransactionID(current) != UpdateTransactionID(tx) {
return false, fmt.Errorf("force retire probationary update: pending transaction changed")
}
if !UpdateVersionsEqual(runningVersion, current.ToVersion) || !pendingUpdateInstalledForHealth(current) {
return false, nil
}
unlockTargets, lockErr := lockRepairMutations(pendingUpdateTargetPaths(current)...)
if lockErr != nil {
return false, fmt.Errorf("force retire probationary update: lock targets: %w", lockErr)
}
defer unlockTargets()
recheck, err := ReadPendingUpdate()
if err != nil {
if os.IsNotExist(err) {
return true, nil
}
return false, err
}
if !reflect.DeepEqual(current, recheck) {
return false, fmt.Errorf("force retire probationary update: pending transaction changed while waiting")
}
// Require only that the replacement is still installed; skip backup digest
// matching so a broken rollback artifact cannot permanently lock updates.
verifyInstalled := func() error {
switch recheck.TargetKind {
case "app-bundle":
if err := VerifyAppBundleUpdateHandoffTarget(recheck); err != nil {
return err
}
case "file":
if _, _, err := installedFileUpdateTargets(recheck, true); err != nil {
return err
}
default:
return fmt.Errorf("unsupported target kind %q", recheck.TargetKind)
}
return nil
}
if err := removePendingUpdateExactVerified(recheck, verifyInstalled); err != nil {
return false, err
}
removeUpdateBackups(recheck)
slog.Warn("repair: force-retired a probationary pending update after explicit abandon",
"toVersion", recheck.ToVersion, "target", recheck.TargetPath)
return true, nil
}
// pendingUpdateInstalledForHealth requires transaction-bound evidence for the
// complete replacement and rollback unit. It intentionally treats missing or
// drifted evidence as uninstalled so reconciliation can take the existing
@@ -2129,7 +2337,7 @@ func markUpdateHealthyInvocation(runningVersion, expectedCreatedAt, expectedTran
}
return err
}
if strings.TrimSpace(runningVersion) != strings.TrimSpace(tx.ToVersion) {
if !UpdateVersionsEqual(runningVersion, tx.ToVersion) {
return nil
}
if expected := strings.TrimSpace(expectedCreatedAt); expected != "" && expected != strings.TrimSpace(tx.CreatedAt) {
@@ -2159,7 +2367,7 @@ func markUpdateHealthyMatching(runningVersion, expectedCreatedAt, expectedTransa
}
return err
}
if strings.TrimSpace(runningVersion) != strings.TrimSpace(tx.ToVersion) {
if !UpdateVersionsEqual(runningVersion, tx.ToVersion) {
return nil
}
if expected := strings.TrimSpace(expectedCreatedAt); expected != "" && expected != strings.TrimSpace(tx.CreatedAt) {
+93
View File
@@ -290,6 +290,99 @@ func TestReconcilePendingFileUpdateLeavesBoundProbationaryReleaseUnit(t *testing
}
}
func TestUpdateVersionsEqualNormalizesLeadingV(t *testing.T) {
if !UpdateVersionsEqual("1.19.7", "v1.19.7") {
t.Fatal("expected 1.19.7 and v1.19.7 to match")
}
if !UpdateVersionsEqual("v1.20.0", "V1.20.0") {
t.Fatal("expected case-insensitive v prefix normalization")
}
if UpdateVersionsEqual("v1.19.7", "v1.19.8") {
t.Fatal("different versions must not match")
}
if UpdateVersionsEqual("", "v1") {
t.Fatal("empty version must not match")
}
}
func TestReconcilePendingFileUpdateCommitsStaleProbationaryReleaseUnit(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
target := filepath.Join(t.TempDir(), "reasonix-desktop")
originalExecutable := repairExecutable
repairExecutable = func() (string, error) { return filepath.Join(filepath.Dir(target), "reasonix-guard"), nil }
t.Cleanup(func() { repairExecutable = originalExecutable })
// Do not rewrite CreatedAt: it is part of the transaction identity that
// install-evidence binding requires. Force the stale decision instead.
pendingUpdateHealthIsStaleOverride = func(*UpdateTransaction) bool { return true }
t.Cleanup(func() { pendingUpdateHealthIsStaleOverride = nil })
if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
t.Fatal(err)
}
tx, err := PrepareFileUpdate("v1", "v2", target)
if err != nil {
t.Fatal(err)
}
receipt, err := PublishClaimedFileUpdateMemberExact(tx, target, []byte("new"), 0o700)
if err != nil {
t.Fatal(err)
}
if _, err := RecordClaimedFileUpdateInstalled(tx, receipt); err != nil {
t.Fatal(err)
}
// Version-prefix mismatch used to skip health commits; reconcile must still heal.
result, err := ReconcilePendingUpdate("2")
if err != nil {
t.Fatalf("reconcile stale probationary update: %v", err)
}
if !result.Healthy || !result.Cleared || result.AwaitingHealth || result.Pending {
t.Fatalf("reconcile result = %+v", result)
}
if PendingUpdateExists() {
t.Fatal("stale probationary transaction survived reconcile")
}
if got, err := os.ReadFile(target); err != nil || string(got) != "new" {
t.Fatalf("installed target changed = %q, %v", got, err)
}
}
func TestAbandonPendingUpdateCommitsProbationaryReleaseUnit(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
target := filepath.Join(t.TempDir(), "reasonix-desktop")
originalExecutable := repairExecutable
repairExecutable = func() (string, error) { return filepath.Join(filepath.Dir(target), "reasonix-guard"), nil }
t.Cleanup(func() { repairExecutable = originalExecutable })
if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
t.Fatal(err)
}
tx, err := PrepareFileUpdate("v1", "v2", target)
if err != nil {
t.Fatal(err)
}
receipt, err := PublishClaimedFileUpdateMemberExact(tx, target, []byte("new"), 0o700)
if err != nil {
t.Fatal(err)
}
if _, err := RecordClaimedFileUpdateInstalled(tx, receipt); err != nil {
t.Fatal(err)
}
result, err := AbandonPendingUpdate("v2")
if err != nil {
t.Fatalf("abandon: %v", err)
}
if !result.Healthy || !result.Cleared {
t.Fatalf("abandon result = %+v", result)
}
if PendingUpdateExists() {
t.Fatal("pending transaction survived abandon")
}
if got, err := os.ReadFile(target); err != nil || string(got) != "new" {
t.Fatalf("installed target changed = %q, %v", got, err)
}
}
func TestReconcilePendingFileUpdateRollsBackPublishedReleaseUnit(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
target := filepath.Join(t.TempDir(), "reasonix-desktop")