Merge pull request #7149 from SivanCola/fix/pending-update-recovery

fix(updater): recover abandoned pending transactions / 修复遗留更新事务阻塞
This commit is contained in:
SivanCola authored and GitHub committed 2026-08-02 01:36:30 +08:00
commit 84d3d1ae00
20 files changed
+821 -15

No files matched your search

+19 -2
View File
@@ -6,6 +6,7 @@ package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
@@ -261,9 +262,25 @@ func runLaunch(args []string) int {
} else if failure != nil && result.RolledBack {
fmt.Fprintf(os.Stderr, "Reasonix Guard restored %s after the %s installer failed.\n", result.ToVersion, result.FromVersion)
}
forceSafeMode := false
if result, err := repair.ReconcilePendingUpdate(version); err != nil {
if !errors.Is(err, repair.ErrPendingUpdateAwaitingHealth) {
fmt.Fprintln(os.Stderr, "previous update recovery failed:", err)
if result.MixedInstall {
fmt.Fprintln(os.Stderr, "error: the installation may mix two releases; refusing to start. Re-run reasonix-guard to retry recovery, or reinstall Reasonix.")
return 1
}
forceSafeMode = true
}
} else if result.RolledBack {
fmt.Fprintf(os.Stderr, "Reasonix Guard restored %s before launch after an unfinished %s update.\n", result.FromVersion, result.ToVersion)
} else if result.Cleared {
fmt.Fprintf(os.Stderr, "Reasonix Guard cleared an abandoned %s update before launch.\n", result.ToVersion)
}
tracker := repair.NewStartupTracker("")
useSafeMode := *safeMode
if !*safeMode && tracker.SafeModeRecommended() {
useSafeMode := *safeMode || forceSafeMode
if !useSafeMode && tracker.SafeModeRecommended() {
if result, err := repair.RollbackPendingUpdate(); err != nil {
fmt.Fprintln(os.Stderr, "update rollback failed:", err)
if result.MixedInstall {
@@ -150,6 +150,19 @@ await act(async () => {
ok(document.getElementById("banner-status")?.textContent === "authorizing", "deb install starts authorizing");
ok(document.getElementById("settings-status")?.textContent === "authorizing", "authorizing state is shared");
await act(async () => {
__emitMockUpdater({
requestId: installAttempts[0].requestId,
version: installAttempts[0].version,
channel: "preview",
phase: "recovering",
received: 42,
total: 42,
});
});
ok(document.getElementById("banner-status")?.textContent === "recovering", "stale update recovery is shared");
ok(document.getElementById("settings-status")?.textContent === "recovering", "settings observes stale update recovery");
await act(async () => {
__emitMockUpdater({
requestId: installAttempts[0].requestId,
@@ -6802,6 +6802,7 @@ function UpdatesSection({
status.kind === "downloading" ||
status.kind === "verifying" ||
status.kind === "authorizing" ||
status.kind === "recovering" ||
status.kind === "installing";
const updateStatus =
status.kind === "checking" ? t("updater.checking") :
@@ -6815,6 +6816,7 @@ function UpdatesSection({
status.kind === "verifying" ? t("updater.verifying") :
status.kind === "downloaded" ? t("updater.downloaded", { v: status.info.latest }) :
status.kind === "authorizing" ? t("updater.authorizing") :
status.kind === "recovering" ? t("updater.recovering") :
status.kind === "installing" ? (
status.info?.requiresElevation || status.info?.installMode === "deb"
? t("updater.installingPackage")
@@ -88,6 +88,8 @@ export function UpdateBanner({
);
case "authorizing":
return <div className="banner banner--update">{t("updater.authorizing")}</div>;
case "recovering":
return <div className="banner banner--update">{t("updater.recovering")}</div>;
case "installing":
return (
<div className="banner banner--update">
+1 -1
View File
@@ -1805,7 +1805,7 @@ export interface UpdateProgress {
requestId: string;
version: string;
channel: "stable" | "preview" | string;
phase: "downloading" | "verifying" | "downloaded" | "authorizing" | "installing" | "done" | "error";
phase: "downloading" | "verifying" | "downloaded" | "authorizing" | "recovering" | "installing" | "done" | "error";
received: number;
total: number;
err?: string;
+5 -1
View File
@@ -15,6 +15,7 @@ export type UpdateStatus =
| { kind: "verifying"; info: UpdateInfo }
| { kind: "downloaded"; info: UpdateInfo }
| { kind: "authorizing"; info?: UpdateInfo }
| { kind: "recovering"; info?: UpdateInfo }
| { kind: "installing"; info?: UpdateInfo }
| { kind: "done" }
| { kind: "error"; message: string; info?: UpdateInfo; manualHint?: boolean };
@@ -47,6 +48,7 @@ function offersManualFallback(message: string): boolean {
return (
low.includes("authorization failed") ||
low.includes("manual update required") ||
low.includes("could not safely finish the previous update") ||
low.includes("pkexec") ||
low.includes("sudo apt install")
);
@@ -138,7 +140,7 @@ function useUpdaterInternal(): Updater {
const accepted =
((p.phase === "downloading" || p.phase === "verifying") && operation.kind === "downloading") ||
(p.phase === "downloaded" && (operation.kind === "downloading" || operation.kind === "installing")) ||
((p.phase === "authorizing" || p.phase === "installing" || p.phase === "done") && operation.kind === "installing") ||
((p.phase === "authorizing" || p.phase === "recovering" || p.phase === "installing" || p.phase === "done") && operation.kind === "installing") ||
(p.phase === "error" && (operation.kind === "downloading" || operation.kind === "installing"));
if (!accepted) return;
if (p.phase === "downloaded" || p.phase === "done" || p.phase === "error") {
@@ -158,6 +160,8 @@ function useUpdaterInternal(): Updater {
return info ? { kind: "downloaded", info: { ...info, downloaded: true } } : cur;
case "authorizing":
return { kind: "authorizing", info };
case "recovering":
return { kind: "recovering", info };
case "installing":
return { kind: "installing", info };
case "done":
+1
View File
@@ -2786,6 +2786,7 @@ export const en = {
"updater.downloading": "Downloading… {done} MB / {total} MB ({pct}%)",
"updater.verifying": "Verifying signature…",
"updater.authorizing": "Waiting for administrator authorization…",
"updater.recovering": "Finishing recovery from the previous update…",
"updater.installing": "Installing — Reasonix will restart…",
"updater.installingPackage": "Installing via the system package manager…",
"updater.applying": "Installing…",
+1
View File
@@ -1926,6 +1926,7 @@ export const zhTW: Record<DictKey, string> = {
"updater.downloading": "下載中… {done} MB / {total} MB({pct}%)",
"updater.verifying": "正在驗證簽章…",
"updater.authorizing": "等待管理員授權…",
"updater.recovering": "正在復原上次未完成的更新…",
"updater.installing": "正在安裝,應用程式即將重新啟動…",
"updater.installingPackage": "正在透過系統套件管理員安裝…",
"updater.applying": "正在安裝…",
+1
View File
@@ -2789,6 +2789,7 @@ export const zh: Record<DictKey, string> = {
"updater.downloading": "下载中… {done} MB / {total} MB({pct}%)",
"updater.verifying": "正在验证签名…",
"updater.authorizing": "等待管理员授权…",
"updater.recovering": "正在恢复上次未完成的更新…",
"updater.installing": "正在安装,应用即将重启…",
"updater.installingPackage": "正在通过系统包管理器安装…",
"updater.applying": "正在安装…",
+31 -7
View File
@@ -1,6 +1,7 @@
package main
import (
"errors"
"fmt"
"reasonix/internal/repair"
@@ -15,13 +16,14 @@ const (
)
type desktopStartupRecoveryDeps struct {
recoverFailedInstall func() (repair.UpdateRollbackResult, *repair.UpdateApplyFailure, error)
rollbackPendingUpdate func() (repair.UpdateRollbackResult, error)
repairGlobalConfig func() error
choose func() desktopRecoveryChoice
markClean func() error
relaunch func(string) error
report func(string)
recoverFailedInstall func() (repair.UpdateRollbackResult, *repair.UpdateApplyFailure, error)
reconcilePendingUpdate func() (repair.PendingUpdateReconcileResult, error)
rollbackPendingUpdate func() (repair.UpdateRollbackResult, error)
repairGlobalConfig func() error
choose func() desktopRecoveryChoice
markClean func() error
relaunch func(string) error
report func(string)
}
// runDesktopStartupRecovery mirrors Guard's preflight for the macOS bundle,
@@ -64,6 +66,28 @@ func runDesktopStartupRecovery(recommended, explicitSafeMode bool, deps desktopS
}
}
// Reconcile abandoned pre-publish transactions on every launch instead of
// waiting for a crash loop. A target release still in its health-confirmation
// window remains probationary and is intentionally left untouched.
if deps.reconcilePendingUpdate != nil {
result, err := deps.reconcilePendingUpdate()
if err != nil && !errors.Is(err, repair.ErrPendingUpdateAwaitingHealth) {
report("previous update recovery failed: " + err.Error())
if result.MixedInstall {
return safeMode, false
}
return true, true
}
if result.RolledBack {
return safeMode, relaunchRestored(repair.UpdateRollbackResult{
RolledBack: true,
FromVersion: result.ToVersion,
ToVersion: result.FromVersion,
TargetPath: result.TargetPath,
})
}
}
// An explicit Safe Mode request is an operator override. Preserve Guard's
// behavior by skipping crash-loop rollback and the recovery prompt.
if explicitSafeMode || !recommended {
+4 -1
View File
@@ -13,7 +13,10 @@ import (
func preparePackagedStartupRecovery(tracker *repair.StartupTracker, recommended, explicitSafeMode bool) (bool, bool) {
return runDesktopStartupRecovery(recommended, explicitSafeMode, desktopStartupRecoveryDeps{
recoverFailedInstall: repair.RecoverFailedInstall,
recoverFailedInstall: repair.RecoverFailedInstall,
reconcilePendingUpdate: func() (repair.PendingUpdateReconcileResult, error) {
return repair.ReconcilePendingUpdate(version)
},
rollbackPendingUpdate: repair.RollbackPendingUpdate,
repairGlobalConfig: func() error {
_, err := repair.InspectAndRepairConfig(repair.ConfigOptions{Apply: true, OnlyScope: "global"})
+39 -2
View File
@@ -10,14 +10,51 @@ import (
func TestDesktopStartupRecoveryNormalLaunch(t *testing.T) {
recovered := false
reconciled := false
safeMode, proceed := runDesktopStartupRecovery(false, false, desktopStartupRecoveryDeps{
recoverFailedInstall: func() (repair.UpdateRollbackResult, *repair.UpdateApplyFailure, error) {
recovered = true
return repair.UpdateRollbackResult{}, nil, nil
},
reconcilePendingUpdate: func() (repair.PendingUpdateReconcileResult, error) {
reconciled = true
return repair.PendingUpdateReconcileResult{}, nil
},
})
if !recovered || safeMode || !proceed {
t.Fatalf("recovered=%v safeMode=%v proceed=%v, want true false true", recovered, safeMode, proceed)
if !recovered || !reconciled || safeMode || !proceed {
t.Fatalf("recovered=%v reconciled=%v safeMode=%v proceed=%v", recovered, reconciled, safeMode, proceed)
}
}
func TestDesktopStartupRecoveryReconcilesAbandonedUpdateBeforeCrashThreshold(t *testing.T) {
var relaunched string
markedClean := false
safeMode, proceed := runDesktopStartupRecovery(false, false, desktopStartupRecoveryDeps{
reconcilePendingUpdate: func() (repair.PendingUpdateReconcileResult, error) {
return repair.PendingUpdateReconcileResult{
Pending: true,
RolledBack: true,
FromVersion: "v1",
ToVersion: "v2",
TargetPath: "/Applications/Reasonix.app",
}, nil
},
markClean: func() error { markedClean = true; return nil },
relaunch: func(path string) error { relaunched = path; return nil },
})
if safeMode || proceed || !markedClean || relaunched != "/Applications/Reasonix.app" {
t.Fatalf("safeMode=%v proceed=%v markedClean=%v relaunched=%q", safeMode, proceed, markedClean, relaunched)
}
}
func TestDesktopStartupRecoveryLeavesProbationaryUpdate(t *testing.T) {
safeMode, proceed := runDesktopStartupRecovery(false, false, desktopStartupRecoveryDeps{
reconcilePendingUpdate: func() (repair.PendingUpdateReconcileResult, error) {
return repair.PendingUpdateReconcileResult{Pending: true, AwaitingHealth: true}, repair.ErrPendingUpdateAwaitingHealth
},
})
if safeMode || !proceed {
t.Fatalf("safeMode=%v proceed=%v", safeMode, proceed)
}
}
+1 -1
View File
@@ -214,7 +214,7 @@ type updateProgress struct {
RequestID string `json:"requestId"`
Version string `json:"version"`
Channel string `json:"channel"`
Phase string `json:"phase"` // downloading | verifying | downloaded | authorizing | installing | done | error
Phase string `json:"phase"` // downloading | verifying | downloaded | authorizing | recovering | installing | done | error
Received int64 `json:"received"`
Total int64 `json:"total"`
Err string `json:"err,omitempty"`
+26
View File
@@ -27,6 +27,11 @@ var errUpdateInProgress = errors.New("update: another download or install is alr
var updaterRequestIDRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`)
var (
pendingUpdateExistsForInstall = repair.PendingUpdateExists
reconcilePendingUpdateForInstall = repair.ReconcilePendingUpdate
)
func validateUpdaterRequest(requestID, selectedChannel, expectedVersion string) (string, string, string, error) {
requestID = strings.TrimSpace(requestID)
if !updaterRequestIDRE.MatchString(requestID) {
@@ -287,6 +292,9 @@ func (a *App) InstallUpdateRequest(selectedChannel, expectedVersion, requestID s
if artifactKindFromMeta(meta.ArtifactKind) != artifactKindFromMeta(wantKind) {
return a.failUpdate(requestID, selectedChannel, expectedVersion, errUpdateCacheMismatch)
}
if err := a.reconcilePendingUpdateBeforeInstall(requestID, meta); err != nil {
return err
}
switch profile.Mode {
case installModeDeb:
@@ -296,6 +304,24 @@ func (a *App) InstallUpdateRequest(selectedChannel, expectedVersion, requestID s
}
}
// reconcilePendingUpdateBeforeInstall runs before install-mode dispatch so a
// profile change cannot let the deb or portable path bypass an unfinished
// release-unit transaction from the previous attempt.
func (a *App) reconcilePendingUpdateBeforeInstall(requestID string, meta *cachedUpdate) error {
if pendingUpdateExistsForInstall() {
a.emitProgress(requestID, meta.Channel, meta.Version, "recovering", meta.Size, meta.Size, "")
}
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")
} else {
err = fmt.Errorf("update recovery: could not safely finish the previous update: %w", err)
}
return a.failUpdate(requestID, meta.Channel, meta.Version, 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
+126
View File
@@ -5,6 +5,7 @@ package main
import (
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
@@ -21,6 +22,9 @@ import (
const (
macBundleID = "com.wails.reasonix-desktop"
macUpdateHandoffArg = "--reasonix-mac-update-handoff"
macHandoffReadyFD = 3
macHandoffProceedFD = 4
macHandoffReadyWait = 5 * time.Second
// macUpdateHandoffLockTimeout covers concurrent Guard rollback/prepare while
// the critical directory swap runs. PID wait happens before the lock so a
// long exit wait does not starve unrelated repairs.
@@ -104,6 +108,24 @@ func applyMac(zipPath, targetVersion string) error {
if err != nil {
return err
}
readyReader, readyWriter, err := os.Pipe()
if err != nil {
if _, cancelErr := cancelMacUpdateHandoff(tx, macUpdateHandoffLockTimeout); cancelErr != nil {
return fmt.Errorf("create macOS update readiness pipe: %w; cancel prepared update: %v", err, cancelErr)
}
return fmt.Errorf("create macOS update readiness pipe: %w", err)
}
proceedReader, proceedWriter, err := os.Pipe()
if err != nil {
_ = readyReader.Close()
_ = readyWriter.Close()
if _, cancelErr := cancelMacUpdateHandoff(tx, macUpdateHandoffLockTimeout); cancelErr != nil {
return fmt.Errorf("create macOS update proceed pipe: %w; cancel prepared update: %v", err, cancelErr)
}
return fmt.Errorf("create macOS update proceed pipe: %w", err)
}
defer readyReader.Close()
defer proceedWriter.Close()
// Detach a self-subprocess that holds the shared repair mutation lock for
// the actual mv/ditto window. A shell helper cannot share Go flock keys, so
// the binary that performs the directory swap must take LockRepairMutations.
@@ -112,17 +134,63 @@ func applyMac(zipPath, targetVersion string) error {
"-to-version", tx.ToVersion,
"-created-at", tx.CreatedAt,
"-transaction-id", repair.UpdateTransactionID(tx),
"-ready-fd", fmt.Sprintf("%d", macHandoffReadyFD),
"-proceed-fd", fmt.Sprintf("%d", macHandoffProceedFD),
)
cmd.ExtraFiles = []*os.File{readyWriter, proceedReader}
if err := cmd.Start(); err != nil {
_ = readyWriter.Close()
_ = proceedReader.Close()
if _, cancelErr := cancelMacUpdateHandoff(tx, macUpdateHandoffLockTimeout); cancelErr != nil {
return fmt.Errorf("%w; cancel prepared update: %v", err, cancelErr)
}
return err
}
_ = readyWriter.Close()
_ = proceedReader.Close()
abortHandoff := func(cause error) error {
_ = proceedWriter.Close()
_ = cmd.Process.Kill()
_ = cmd.Wait()
cancelled, cancelErr := cancelMacUpdateHandoff(tx, macUpdateHandoffLockTimeout)
if cancelErr != nil {
return fmt.Errorf("%w; cancel prepared update: %v", cause, cancelErr)
}
if cleanupErr := cleanupMacHandoffStaging(cancelled); cleanupErr != nil {
return fmt.Errorf("%w; cleanup prepared update: %v", cause, cleanupErr)
}
return cause
}
if err := waitForMacHandoffReady(readyReader, macHandoffReadyWait); err != nil {
return abortHandoff(fmt.Errorf("macOS update helper did not become ready: %w", err))
}
if _, err := io.WriteString(proceedWriter, "go"); err != nil {
return abortHandoff(fmt.Errorf("release macOS update helper: %w", err))
}
if err := proceedWriter.Close(); err != nil {
return abortHandoff(fmt.Errorf("release macOS update helper: %w", err))
}
handedOff = true
return nil
}
func waitForMacHandoffReady(reader *os.File, timeout time.Duration) error {
if reader == nil {
return fmt.Errorf("readiness pipe is unavailable")
}
if err := reader.SetReadDeadline(time.Now().Add(timeout)); err != nil {
return err
}
buf := make([]byte, len("ready"))
if _, err := io.ReadFull(reader, buf); err != nil {
return err
}
if string(buf) != "ready" {
return fmt.Errorf("unexpected readiness response")
}
return nil
}
// maybeRunMacUpdateHandoff handles the detached self-update child before Wails
// or single-instance setup runs.
func maybeRunMacUpdateHandoff(args []string) (handled bool, exitCode int) {
@@ -141,6 +209,8 @@ type macUpdateHandoffConfig struct {
ToVersion string
CreatedAt string
TransactionID string
ReadyFD int
ProceedFD int
}
func parseMacUpdateHandoffArgs(args []string) (macUpdateHandoffConfig, error) {
@@ -149,6 +219,8 @@ func parseMacUpdateHandoffArgs(args []string) (macUpdateHandoffConfig, error) {
fs.StringVar(&cfg.ToVersion, "to-version", "", "pending update target version")
fs.StringVar(&cfg.CreatedAt, "created-at", "", "pending update creation timestamp")
fs.StringVar(&cfg.TransactionID, "transaction-id", "", "complete pending update identity")
fs.IntVar(&cfg.ReadyFD, "ready-fd", 0, "parent readiness pipe")
fs.IntVar(&cfg.ProceedFD, "proceed-fd", 0, "parent proceed pipe")
if err := fs.Parse(args); err != nil {
return macUpdateHandoffConfig{}, err
}
@@ -161,6 +233,10 @@ func parseMacUpdateHandoffArgs(args []string) (macUpdateHandoffConfig, error) {
if cfg.ToVersion == "" || cfg.CreatedAt == "" || cfg.TransactionID == "" {
return macUpdateHandoffConfig{}, fmt.Errorf("missing required handoff arguments")
}
if (cfg.ReadyFD == 0) != (cfg.ProceedFD == 0) ||
(cfg.ReadyFD != 0 && (cfg.ReadyFD < 3 || cfg.ProceedFD < 3 || cfg.ReadyFD == cfg.ProceedFD)) {
return macUpdateHandoffConfig{}, fmt.Errorf("invalid handoff pipe arguments")
}
return cfg, nil
}
@@ -193,6 +269,18 @@ func runMacUpdateHandoff(cfg macUpdateHandoffConfig) int {
logf("pending update does not match handoff identity")
return 1
}
if err := completeMacHandoffHandshake(cfg); err != nil {
logf("macOS update parent handshake failed: %v", err)
if cancelled, cancelErr := cancelMacUpdateHandoff(pending, macUpdateHandoffLockTimeout); cancelErr == nil {
cleanupStaging(cancelled)
if !macProcessAlive(cancelled.HandoffOwnerPID) {
_ = openCommand(cancelled.TargetPath).Start()
}
} else {
logf("failed to cancel unacknowledged update handoff: %v", cancelErr)
}
return 1
}
logf("macOS update handoff started for PID %d", pending.HandoffOwnerPID)
// Wait for the exact desktop PID before taking mutation locks so a long
@@ -451,6 +539,44 @@ func runMacUpdateHandoff(cfg macUpdateHandoffConfig) int {
return 0
}
func completeMacHandoffHandshake(cfg macUpdateHandoffConfig) error {
if cfg.ReadyFD == 0 && cfg.ProceedFD == 0 {
return nil
}
ready := os.NewFile(uintptr(cfg.ReadyFD), "reasonix-update-ready")
proceed := os.NewFile(uintptr(cfg.ProceedFD), "reasonix-update-proceed")
if ready == nil || proceed == nil {
if ready != nil {
_ = ready.Close()
}
if proceed != nil {
_ = proceed.Close()
}
return fmt.Errorf("handoff pipe is unavailable")
}
if _, err := io.WriteString(ready, "ready"); err != nil {
_ = ready.Close()
_ = proceed.Close()
return fmt.Errorf("signal readiness: %w", err)
}
if err := ready.Close(); err != nil {
_ = proceed.Close()
return fmt.Errorf("signal readiness: %w", err)
}
buf := make([]byte, len("go"))
if _, err := io.ReadFull(proceed, buf); err != nil {
_ = proceed.Close()
return fmt.Errorf("wait for parent release: %w", err)
}
if err := proceed.Close(); err != nil {
return fmt.Errorf("wait for parent release: %w", err)
}
if string(buf) != "go" {
return fmt.Errorf("unexpected parent release response")
}
return nil
}
func retainMacHandoffNode(path, suffix string) (string, error) {
for attempt := 0; attempt < 16; attempt++ {
retained := fmt.Sprintf(
+146
View File
@@ -989,6 +989,152 @@ func TestMacUpdateHandoffParserRejectsFilesystemPaths(t *testing.T) {
}
}
func TestMacUpdateHandoffParserRequiresCompletePipePair(t *testing.T) {
_, err := parseMacUpdateHandoffArgs([]string{
"-to-version", "v2",
"-created-at", "2026-07-28T00:00:00Z",
"-transaction-id", strings.Repeat("a", 64),
"-ready-fd", "3",
})
if err == nil || !strings.Contains(err.Error(), "pipe arguments") {
t.Fatalf("partial pipe pair error = %v", err)
}
}
func TestMacUpdateHandoffHandshakeWaitsForParentRelease(t *testing.T) {
readyReader, readyWriter, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
proceedReader, proceedWriter, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer readyReader.Close()
defer proceedWriter.Close()
done := make(chan error, 1)
go func() {
done <- completeMacHandoffHandshake(macUpdateHandoffConfig{
ReadyFD: int(readyWriter.Fd()),
ProceedFD: int(proceedReader.Fd()),
})
}()
if err := waitForMacHandoffReady(readyReader, time.Second); err != nil {
t.Fatal(err)
}
select {
case err := <-done:
t.Fatalf("handshake completed before parent release: %v", err)
default:
}
if _, err := proceedWriter.Write([]byte("go")); err != nil {
t.Fatal(err)
}
if err := proceedWriter.Close(); err != nil {
t.Fatal(err)
}
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("handshake did not finish after parent release")
}
}
func TestMacUpdateHandoffHandshakeRejectsParentExit(t *testing.T) {
readyReader, readyWriter, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
proceedReader, proceedWriter, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer readyReader.Close()
if err := proceedWriter.Close(); err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() {
done <- completeMacHandoffHandshake(macUpdateHandoffConfig{
ReadyFD: int(readyWriter.Fd()),
ProceedFD: int(proceedReader.Fd()),
})
}()
if err := waitForMacHandoffReady(readyReader, time.Second); err != nil {
t.Fatal(err)
}
select {
case err := <-done:
if err == nil || !strings.Contains(err.Error(), "parent release") {
t.Fatalf("parent exit error = %v", err)
}
case <-time.After(time.Second):
t.Fatal("handshake did not observe parent exit")
}
}
func TestMacUpdateHandoffParentExitCancelsPreparedTransaction(t *testing.T) {
root := t.TempDir()
oldApp := filepath.Join(root, "Reasonix.app")
newApp := filepath.Join(root, "staging", "Reasonix.app")
pending := filepath.Join(root, "pending.json")
for _, dir := range []string{oldApp, newApp} {
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(oldApp, "marker"), []byte("old"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(newApp, "marker"), []byte("new"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(pending, []byte("pending"), 0o600); err != nil {
t.Fatal(err)
}
tx := &repair.UpdateTransaction{
ToVersion: "v2",
CreatedAt: "2026-07-28T00:00:00Z",
TargetKind: "app-bundle",
TargetPath: oldApp,
BackupPath: oldApp + ".reasonix-update-backup",
HandoffAppPath: newApp,
HandoffStagingPath: filepath.Dir(newApp),
HandoffOwnerPID: os.Getpid(),
}
installMacHandoffTestDeps(t, tx, pending, filepath.Join(root, "update.log"), nil)
readyReader, readyWriter, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
proceedReader, proceedWriter, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer readyReader.Close()
if err := proceedWriter.Close(); err != nil {
t.Fatal(err)
}
cfg := macHandoffConfigFor(tx)
cfg.ReadyFD = int(readyWriter.Fd())
cfg.ProceedFD = int(proceedReader.Fd())
if code := runMacUpdateHandoff(cfg); code == 0 {
t.Fatal("handoff accepted a parent that exited before release")
}
if _, err := os.Stat(pending); !os.IsNotExist(err) {
t.Fatalf("abandoned pending transaction survived: %v", err)
}
if got, err := os.ReadFile(filepath.Join(oldApp, "marker")); err != nil || string(got) != "old" {
t.Fatalf("original app changed = %q, %v", got, err)
}
}
func TestMacHandoffRenameDoesNotReplaceExistingDestination(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
+45
View File
@@ -126,6 +126,51 @@ func TestUpdaterNativeOperationsFailFastWhileBusy(t *testing.T) {
finishSecond()
}
func TestUpdaterReconcilesPendingUpdateBeforeInstallModeDispatch(t *testing.T) {
originalExists := pendingUpdateExistsForInstall
originalReconcile := reconcilePendingUpdateForInstall
t.Cleanup(func() {
pendingUpdateExistsForInstall = originalExists
reconcilePendingUpdateForInstall = originalReconcile
})
called := false
pendingUpdateExistsForInstall = func() bool { return true }
reconcilePendingUpdateForInstall = func(runningVersion string) (repair.PendingUpdateReconcileResult, error) {
called = true
if runningVersion != version {
t.Fatalf("running version = %q, want %q", runningVersion, version)
}
return repair.PendingUpdateReconcileResult{Pending: true, Cleared: true}, nil
}
meta := &cachedUpdate{Channel: "preview", Version: "v1.18.0-preview.65", Size: 42}
if err := (&App{}).reconcilePendingUpdateBeforeInstall("install-1", meta); err != nil {
t.Fatal(err)
}
if !called {
t.Fatal("pending update reconciliation was skipped")
}
}
func TestUpdaterBlocksInstallWhilePreviousReleaseAwaitsHealth(t *testing.T) {
originalExists := pendingUpdateExistsForInstall
originalReconcile := reconcilePendingUpdateForInstall
t.Cleanup(func() {
pendingUpdateExistsForInstall = originalExists
reconcilePendingUpdateForInstall = originalReconcile
})
pendingUpdateExistsForInstall = func() bool { return true }
reconcilePendingUpdateForInstall = func(string) (repair.PendingUpdateReconcileResult, error) {
return repair.PendingUpdateReconcileResult{Pending: true, AwaitingHealth: true}, repair.ErrPendingUpdateAwaitingHealth
}
meta := &cachedUpdate{Channel: "preview", Version: "v1.18.0-preview.65", Size: 42}
err := (&App{}).reconcilePendingUpdateBeforeInstall("install-1", meta)
if err == nil || !strings.Contains(err.Error(), "startup health check") {
t.Fatalf("health-check recovery error = %v", err)
}
}
func TestExpectedUpdateVersionRejectsAdvancedPointer(t *testing.T) {
if err := ensureExpectedUpdateVersion("preview", "v1.18.0-preview.1", "v1.18.0-preview.2"); err == nil {
t.Fatal("advanced pointer unexpectedly matched the checked version")
+140
View File
@@ -5,6 +5,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
@@ -90,6 +91,28 @@ type UpdateRollbackResult struct {
MixedInstall bool `json:"mixedInstall,omitempty"`
}
// ErrPendingUpdateAwaitingHealth reports that the currently running release is
// still the probationary target of a prior update. Callers must not cancel or
// roll it back merely to start another update; the normal startup health
// confirmation owns that transition.
var ErrPendingUpdateAwaitingHealth = errors.New("previous update is awaiting startup health confirmation")
// 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.
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"`
FromVersion string `json:"fromVersion,omitempty"`
ToVersion string `json:"toVersion,omitempty"`
TargetPath string `json:"targetPath,omitempty"`
}
// 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.
@@ -1587,6 +1610,123 @@ func HasPendingUpdate() bool {
return err == nil
}
// PendingUpdateExists reports the on-disk marker even when its contents are
// malformed. It is intended only for progress/UI decisions; callers must use
// ReadPendingUpdate or ReconcilePendingUpdate before authorizing mutations.
func PendingUpdateExists() bool {
path := PendingUpdatePath()
if path == "" {
return false
}
_, err := os.Lstat(path)
return err == nil
}
// ReconcilePendingUpdate resolves an older immutable update transaction before
// startup or a new install. It first attempts the narrow cancellation path,
// which succeeds only while every original target still matches the state
// captured by prepare and no replacement state is durable. If publication has
// started, it falls back to the exact verified rollback path. Both transitions
// re-read the complete transaction under the pending and target mutation locks.
//
// A transaction targeting runningVersion is left untouched only when the
// transaction also proves that its replacement release unit is installed and
// its rollback state is intact. Version equality alone is not installation
// evidence: a same-version/manual launch may observe an abandoned prepare.
func ReconcilePendingUpdate(runningVersion string) (PendingUpdateReconcileResult, error) {
tx, err := ReadPendingUpdate()
if err != nil {
if os.IsNotExist(err) {
return PendingUpdateReconcileResult{}, nil
}
return PendingUpdateReconcileResult{Pending: true}, fmt.Errorf("reconcile pending update: %w", err)
}
result := PendingUpdateReconcileResult{
Pending: true,
FromVersion: tx.FromVersion,
ToVersion: tx.ToVersion,
TargetPath: tx.TargetPath,
}
if strings.TrimSpace(runningVersion) == strings.TrimSpace(tx.ToVersion) &&
pendingUpdateInstalledForHealth(tx) {
result.AwaitingHealth = true
return result, ErrPendingUpdateAwaitingHealth
}
// Cancel is deliberately attempted before rollback. For app bundles it
// requires the original tree and an absent backup; for file release units it
// requires every prepared target and no installed-state sidecar. A failed
// cancel never mutates the transaction or release unit.
if cancelErr := CancelPendingUpdateExact(tx); cancelErr == nil {
// Exact cancellation historically treats a different target version or
// creation time as an inert success. Re-check the public postcondition so
// reconciliation never reports a newer transaction as cleared.
current, currentErr := ReadPendingUpdate()
if os.IsNotExist(currentErr) {
result.Cleared = true
cleanupPendingUpdateStaging(tx)
return result, nil
}
if currentErr != nil {
return result, fmt.Errorf("reconcile pending update: verify cancellation: %w", currentErr)
}
if !reflect.DeepEqual(tx, current) {
return result, fmt.Errorf("reconcile pending update: pending transaction changed during cancellation")
}
return result, fmt.Errorf("reconcile pending update: transaction remained after cancellation")
}
rollback, rollbackErr := RollbackPendingUpdateExact(tx)
if rollbackErr != nil {
result.RolledBack = rollback.RolledBack
result.MixedInstall = rollback.MixedInstall
return result, fmt.Errorf("reconcile pending update: %w", rollbackErr)
}
if !rollback.RolledBack {
// Another exact owner may have committed or cancelled the transaction
// between the invocation snapshot and the locked transition.
if _, currentErr := ReadPendingUpdate(); os.IsNotExist(currentErr) {
return PendingUpdateReconcileResult{}, nil
}
return result, fmt.Errorf("reconcile pending update: transaction could not be cancelled or rolled back")
}
result.RolledBack = true
cleanupPendingUpdateStaging(tx)
return result, 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
// exact cancel/rollback paths instead of trusting a version string.
func pendingUpdateInstalledForHealth(tx *UpdateTransaction) bool {
if tx == nil {
return false
}
switch tx.TargetKind {
case "app-bundle":
return VerifyAppBundleUpdateHandoffTarget(tx) == nil &&
VerifyAppBundleUpdateHandoffBackup(tx) == nil
case "file":
_, bound, err := installedFileUpdateTargets(tx, true)
return err == nil && bound
default:
return false
}
}
// cleanupPendingUpdateStaging is best-effort after the pending transaction has
// been safely committed away. CleanupAppBundleUpdateHandoffStaging verifies the
// complete recorded tree before removal, so drifted or recreated paths survive.
func cleanupPendingUpdateStaging(tx *UpdateTransaction) {
if tx == nil || tx.TargetKind != "app-bundle" ||
strings.TrimSpace(tx.HandoffStagingPath) == "" ||
strings.TrimSpace(tx.HandoffStagingTreeID) == "" {
return
}
_ = CleanupAppBundleUpdateHandoffStaging(tx)
}
func readPendingUpdateInvocation() (*UpdateTransaction, string, map[string]string, error) {
tx, err := ReadPendingUpdate()
if err != nil {
+117
View File
@@ -1,10 +1,12 @@
package repair
import (
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
)
@@ -53,6 +55,121 @@ func prepareTestAppBundleHandoff(t *testing.T) (*UpdateTransaction, string) {
return tx, staging
}
func TestReconcilePendingUpdateCancelsAbandonedSameVersionAppHandoff(t *testing.T) {
tx, staging := prepareTestAppBundleHandoff(t)
result, err := ReconcilePendingUpdate(tx.ToVersion)
if err != nil {
t.Fatal(err)
}
if !result.Pending || !result.Cleared || result.RolledBack || result.AwaitingHealth {
t.Fatalf("reconcile result = %+v", result)
}
if _, err := os.Stat(PendingUpdatePath()); !os.IsNotExist(err) {
t.Fatalf("pending transaction survived reconcile: %v", err)
}
if _, err := os.Stat(staging); !os.IsNotExist(err) {
t.Fatalf("verified handoff staging survived reconcile: %v", err)
}
if err := VerifyAppBundleUpdateHandoffOriginal(tx); err != nil {
t.Fatalf("original bundle changed during reconcile: %v", err)
}
}
func TestReconcilePendingUpdateLeavesProbationaryTarget(t *testing.T) {
tx, staging := prepareTestAppBundleHandoff(t)
if err := os.Rename(tx.TargetPath, tx.BackupPath); err != nil {
t.Fatal(err)
}
if err := os.Rename(tx.HandoffAppPath, tx.TargetPath); err != nil {
t.Fatal(err)
}
result, err := ReconcilePendingUpdate(tx.ToVersion)
if !errors.Is(err, ErrPendingUpdateAwaitingHealth) {
t.Fatalf("reconcile error = %v", err)
}
if !result.Pending || !result.AwaitingHealth || result.Cleared || result.RolledBack {
t.Fatalf("reconcile result = %+v", result)
}
if _, err := ReadPendingUpdate(); err != nil {
t.Fatalf("probationary transaction was removed: %v", err)
}
if _, err := os.Stat(staging); err != nil {
t.Fatalf("probationary staging was removed: %v", err)
}
}
func TestReconcilePendingUpdateRollsBackPublishedAppHandoff(t *testing.T) {
tx, _ := prepareTestAppBundleHandoff(t)
if err := os.Rename(tx.TargetPath, tx.BackupPath); err != nil {
t.Fatal(err)
}
if err := os.Rename(tx.HandoffAppPath, tx.TargetPath); err != nil {
t.Fatal(err)
}
result, err := ReconcilePendingUpdate(tx.FromVersion)
if err != nil {
t.Fatal(err)
}
if !result.Pending || !result.RolledBack || result.Cleared || result.AwaitingHealth {
t.Fatalf("reconcile result = %+v", result)
}
if err := VerifyAppBundleUpdateHandoffOriginal(tx); err != nil {
t.Fatalf("previous app bundle was not restored: %v", err)
}
if _, err := os.Stat(PendingUpdatePath()); !os.IsNotExist(err) {
t.Fatalf("pending transaction survived rollback: %v", err)
}
}
func TestReconcilePendingUpdateRejectsTransactionRewrittenBeforeCancelLock(t *testing.T) {
tx, _ := prepareTestAppBundleHandoff(t)
attempted := make(chan struct{})
allow := make(chan struct{})
originalAcquire := acquirePendingUpdateLock
var once sync.Once
acquirePendingUpdateLock = func() (func(), error) {
blocked := false
once.Do(func() {
blocked = true
close(attempted)
})
if blocked {
<-allow
}
return originalAcquire()
}
t.Cleanup(func() { acquirePendingUpdateLock = originalAcquire })
type outcome struct {
result PendingUpdateReconcileResult
err error
}
done := make(chan outcome, 1)
go func() {
result, err := ReconcilePendingUpdate(tx.FromVersion)
done <- outcome{result: result, err: err}
}()
<-attempted
changed := *tx
changed.ToVersion = "v3"
if err := overwritePendingUpdateForTest(&changed); err != nil {
t.Fatal(err)
}
close(allow)
got := <-done
if got.err == nil || !strings.Contains(got.err.Error(), "changed") {
t.Fatalf("reconcile outcome = %+v, %v", got.result, got.err)
}
current, err := ReadPendingUpdate()
if err != nil || current.FromVersion != changed.FromVersion {
t.Fatalf("rewritten pending update = %+v, %v", current, err)
}
}
func TestClaimPendingAppBundleUpdateHandoffReturnsRecordedPaths(t *testing.T) {
tx, _ := prepareTestAppBundleHandoff(t)
var lockedPaths []string
+101
View File
@@ -64,6 +64,107 @@ func TestFileUpdateRollbackRestoresPreviousBinary(t *testing.T) {
}
}
func TestReconcilePendingFileUpdateCancelsPreparedReleaseUnit(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)
}
result, err := ReconcilePendingUpdate("v2")
if err != nil {
t.Fatal(err)
}
if !result.Cleared || result.RolledBack || result.AwaitingHealth {
t.Fatalf("reconcile result = %+v", result)
}
if got, err := os.ReadFile(target); err != nil || string(got) != "old" {
t.Fatalf("prepared target changed = %q, %v", got, err)
}
for _, file := range tx.Files {
if _, err := os.Stat(file.BackupPath); !os.IsNotExist(err) {
t.Fatalf("prepared backup survived cancellation: %v", err)
}
}
if PendingUpdateExists() {
t.Fatal("pending file update survived cancellation")
}
}
func TestReconcilePendingFileUpdateLeavesBoundProbationaryReleaseUnit(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 := ReconcilePendingUpdate("v2")
if !errors.Is(err, ErrPendingUpdateAwaitingHealth) {
t.Fatalf("reconcile error = %v", err)
}
if !result.Pending || !result.AwaitingHealth || result.Cleared || result.RolledBack {
t.Fatalf("reconcile result = %+v", result)
}
if got, err := os.ReadFile(target); err != nil || string(got) != "new" {
t.Fatalf("probationary target changed = %q, %v", got, err)
}
if !PendingUpdateExists() {
t.Fatal("probationary transaction was removed")
}
}
func TestReconcilePendingFileUpdateRollsBackPublishedReleaseUnit(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)
}
if _, err := PrepareFileUpdate("v1", "v2", target); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(target, []byte("new"), 0o700); err != nil {
t.Fatal(err)
}
result, err := ReconcilePendingUpdate("v1")
if err != nil {
t.Fatal(err)
}
if !result.RolledBack || result.Cleared || result.AwaitingHealth {
t.Fatalf("reconcile result = %+v", result)
}
if got, err := os.ReadFile(target); err != nil || string(got) != "old" {
t.Fatalf("published target was not restored = %q, %v", got, err)
}
if PendingUpdateExists() {
t.Fatal("pending file update survived rollback")
}
}
func TestAppBundleRollbackRestoresWhenLiveBundleIsMissing(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)