mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge pull request #7149 from SivanCola/fix/pending-update-recovery
fix(updater): recover abandoned pending transactions / 修复遗留更新事务阻塞
This commit is contained in:
20 files changed
+821
-15
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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": "正在安裝…",
|
||||
|
||||
@@ -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": "正在安装…",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user