mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge pull request #7615 from SivanCola/fix/updater-recovery-webkit-diagnostics
fix(desktop): recover stuck updates and legacy WebKit / 修复更新卡死与旧版 WebKit
This commit is contained in:
17 files changed
+729
-50
No files matched your search
+35
-6
@@ -292,8 +292,13 @@ type App struct {
|
||||
heartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup
|
||||
|
||||
previousRun repair.PreviousRunObservation
|
||||
// Healthy-update identity is captured before Wails starts. A process may
|
||||
// commit only the complete probationary transaction it actually booted from,
|
||||
// never a rewritten or later same-version retry.
|
||||
healthyUpdateCreatedAt string
|
||||
healthyUpdateTransactionID string
|
||||
// startupReady records that the window reached domReady so LKG config
|
||||
// snapshots are only written after a real UI boot.
|
||||
// snapshots and update health are only committed after a real UI boot.
|
||||
startupReady atomic.Bool
|
||||
}
|
||||
|
||||
@@ -969,8 +974,17 @@ func (a *App) shutdown(context.Context) {
|
||||
a.mu.Unlock()
|
||||
}
|
||||
if a.startupReady.Load() {
|
||||
// Independent last-known-good config snapshot after a successful UI
|
||||
// session. Does not participate in startup decisions.
|
||||
// A visible UI is sufficient health evidence even if the user closes the
|
||||
// window before the delayed post-DOM task runs.
|
||||
if err := a.commitPendingUpdateHealth(); err != nil {
|
||||
slog.Warn("desktop: commit healthy update during shutdown", "err", err)
|
||||
}
|
||||
if archived, err := archiveSupersededPendingUpdateAfterReady(); err != nil {
|
||||
slog.Warn("desktop: retire superseded update during shutdown", "err", err)
|
||||
} else if archived {
|
||||
slog.Info("desktop: archived superseded update transaction during shutdown")
|
||||
}
|
||||
// Independent last-known-good config snapshot after a successful UI session.
|
||||
_ = repair.RecordHealthyConfig(version)
|
||||
}
|
||||
}
|
||||
@@ -1033,17 +1047,32 @@ func (a *App) domReady(_ context.Context) {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
if err := a.commitPendingUpdateHealth(); err != nil {
|
||||
slog.Warn("desktop: commit healthy update", "err", err)
|
||||
}
|
||||
if err := repair.RecordHealthyConfig(version); err != nil {
|
||||
slog.Debug("desktop: record last-known-good config", "err", err)
|
||||
}
|
||||
if archived, err := archiveSupersededLegacyUpdateAfterReady(); err != nil {
|
||||
slog.Warn("desktop: retire superseded legacy update", "err", err)
|
||||
if archived, err := archiveSupersededPendingUpdateAfterReady(); err != nil {
|
||||
slog.Warn("desktop: retire superseded update", "err", err)
|
||||
} else if archived {
|
||||
slog.Info("desktop: archived superseded legacy update transaction")
|
||||
slog.Info("desktop: archived superseded update transaction")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) commitPendingUpdateHealth() error {
|
||||
if a == nil || strings.TrimSpace(a.healthyUpdateCreatedAt) == "" ||
|
||||
strings.TrimSpace(a.healthyUpdateTransactionID) == "" {
|
||||
return nil
|
||||
}
|
||||
return markPendingUpdateHealthyAfterReady(
|
||||
version,
|
||||
a.healthyUpdateCreatedAt,
|
||||
a.healthyUpdateTransactionID,
|
||||
)
|
||||
}
|
||||
|
||||
// --- bound command surface (frontend → controller) ---
|
||||
// Each method guards on a nil controller so a pre-startup or failed-build call is
|
||||
// a no-op, never a panic.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Unicode true
|
||||
Unicode true
|
||||
|
||||
####
|
||||
## Reasonix per-user NSIS installer.
|
||||
@@ -350,7 +350,9 @@ reasonix_normal_install:
|
||||
!else
|
||||
!error "${REASONIX_GUARD} was not found; normal installs require the signed layout activator."
|
||||
!endif
|
||||
ExecWait '"$PLUGINSDIR\${REASONIX_LAYOUT_INSTALLER}" --install-root "$INSTDIR" --version "v${INFO_PRODUCTVERSION}" --activate-staging "$R9" --no-relaunch' $0
|
||||
DetailPrint "Reasonix layout activator output:"
|
||||
nsExec::ExecToLog /OEM '"$PLUGINSDIR\${REASONIX_LAYOUT_INSTALLER}" --install-root "$INSTDIR" --version "v${INFO_PRODUCTVERSION}" --activate-staging "$R9" --no-relaunch'
|
||||
Pop $0
|
||||
StrCmp $0 "0" reasonix_layout_activated
|
||||
DetailPrint "Reasonix layout activation failed with exit code $0; the previous version remains active."
|
||||
RMDir /r "$R9"
|
||||
|
||||
@@ -23,6 +23,7 @@ const projectTreeSource = readFileSync(resolve(here, "../components/ProjectTree.
|
||||
const settingsEntrySource = readFileSync(resolve(here, "../components/SettingsPanelEntry.tsx"), "utf8");
|
||||
const settingsSource = readFileSync(resolve(here, "../components/SettingsPanel.tsx"), "utf8");
|
||||
const markdownSource = readFileSync(resolve(here, "../components/Markdown.tsx"), "utf8");
|
||||
const localPathLinksSource = readFileSync(resolve(here, "../lib/localPathLinks.ts"), "utf8");
|
||||
const i18nSource = readFileSync(resolve(here, "../lib/i18n.tsx"), "utf8");
|
||||
const mainSource = readFileSync(resolve(here, "../main.tsx"), "utf8");
|
||||
const stylesSource = readFileSync(resolve(here, "../styles.css"), "utf8");
|
||||
@@ -106,6 +107,10 @@ ok(
|
||||
markdownSource.includes('import("./MarkdownRenderer")'),
|
||||
"Markdown wrapper loads markdown renderer on demand",
|
||||
);
|
||||
ok(
|
||||
!/String\.raw`[^`]*\(\?<!/s.test(localPathLinksSource),
|
||||
"Markdown local-path support avoids RegExp lookbehind required by newer WebKit",
|
||||
);
|
||||
ok(
|
||||
!/import\s+\{\s*zh\s*\}\s+from\s+["']\.\.\/locales\/zh["']/.test(i18nSource) &&
|
||||
!/import\s+\{\s*zhTW\s*\}\s+from\s+["']\.\.\/locales\/zh-TW["']/.test(i18nSource),
|
||||
|
||||
@@ -70,7 +70,10 @@ eq(pathCount("C: drive"), 0, "bare drive letter is not a path");
|
||||
eq(pathCount("at 10:30 now"), 0, "clock time is not a path");
|
||||
eq(pathCount("version 1.2.3 released"), 0, "version string is not a path");
|
||||
eq(pathCount("http://example.com 正常"), 0, "http URL untouched (GFM handles it)");
|
||||
eq(pathCount("prefixD:\\x\\y.md"), 0, "drive path after a letter is rejected without lookbehind");
|
||||
eq(pathCount("file:///D:/x/y.md"), 1, "file URL drive segment is not double-matched");
|
||||
eq(pathCount("a\\b\\c.md 讨论"), 0, "share-less backslash path in prose is not a UNC path");
|
||||
eq(pathCount("C:\\nas\\share\\x.md"), 1, "drive path does not also become a UNC match");
|
||||
eq(firstPath("见 \\nas\\share\\x.md"), "\\\\nas\\share\\x.md", "real UNC path still matches");
|
||||
eq(firstPath("见 C:\\Program\\ Files\\ (x86) 完成"), "C:\\Program Files (x86)", "escaped-space path with (x86) keeps its closing paren");
|
||||
|
||||
|
||||
@@ -28,22 +28,32 @@ const SENT_PUNCT = ",。;、!?,;!?()";
|
||||
const PATH_CHAR = String.raw`(?:\\[ \t]|[^\s<>"|?*:${SENT_PUNCT}])`;
|
||||
|
||||
// file:/// URLs are matched whole (their `:` and `.` are legal there). Drive
|
||||
// paths require a `(?<![:/A-Za-z])` prefix so `file:///D:` cannot re-match the
|
||||
// `e:` inside `file` or the `D:` after a slash.
|
||||
// and UNC prefix boundaries are checked in JavaScript rather than with regular
|
||||
// expression lookbehind: macOS 12's WebKit rejects `(?<!...)` while loading the
|
||||
// whole lazy Markdown chunk, before any message is rendered.
|
||||
const FILE_RE = new RegExp(String.raw`file:///[^\s<>"|?*${SENT_PUNCT}]+`, "g");
|
||||
const DRIVE_RE = new RegExp(String.raw`(?<![:/A-Za-z])[A-Za-z]:[\\/]${PATH_CHAR}+`, "g");
|
||||
const DRIVE_RE = new RegExp(String.raw`[A-Za-z]:[\\/]${PATH_CHAR}+`, "g");
|
||||
// UNC share paths: the plugin runs on parsed markdown text nodes, where
|
||||
// CommonMark backslash escaping has already folded `\\` into `\`. A share
|
||||
// therefore arrives as `\nas\share\docs\report.md` (single leading
|
||||
// backslash); linkify restores the `\\` prefix so the native opener receives
|
||||
// the real UNC form. The leading `\` is mandatory (the fold always leaves
|
||||
// one) and the `(?<![\\/\w:;:,。、!?()])` guard keeps `C:\nas` or a
|
||||
// plain `a\b` from being mistaken for a share start.
|
||||
// one). The JavaScript prefix guard keeps `C:\nas` or a plain `a\b` from being
|
||||
// mistaken for a share start without requiring WebKit lookbehind support.
|
||||
const UNC_RE = new RegExp(
|
||||
String.raw`(?<![\\/\w:;:,。、!?()])\\(?!\\)[^\s\\<>"|?*:${SENT_PUNCT}]+\\${PATH_CHAR}+`,
|
||||
String.raw`\\(?!\\)[^\s\\<>"|?*:${SENT_PUNCT}]+\\${PATH_CHAR}+`,
|
||||
"g",
|
||||
);
|
||||
|
||||
const DRIVE_PREFIX_RE = /[:/A-Za-z]/;
|
||||
const UNC_PREFIX_RE = /[\\/\w:;:,。、!?()]/;
|
||||
|
||||
function hasValidPrefixBoundary(text: string, start: number, kind: "file" | "drive" | "unc"): boolean {
|
||||
if (kind === "file" || start === 0) return true;
|
||||
const previous = text[start - 1];
|
||||
return kind === "drive" ? !DRIVE_PREFIX_RE.test(previous) : !UNC_PREFIX_RE.test(previous);
|
||||
}
|
||||
|
||||
// Trailing closers that are more likely sentence punctuation than file name
|
||||
// characters. A trailing `)` is only stripped when parens are unbalanced —
|
||||
// "C:\Program Files (x86)" keeps its closing paren, "D:\x\y.md)." loses it.
|
||||
@@ -87,6 +97,9 @@ export function linkifyLocalPaths(text: string): LocalPathSegment[] {
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const match = m;
|
||||
const matchEnd = match.index + match[0].length;
|
||||
if (!hasValidPrefixBoundary(text, match.index, kind)) {
|
||||
continue;
|
||||
}
|
||||
// RegExpExecArray has no start/end — compare via index/length.
|
||||
const overlapped = matches.some((p) => !(matchEnd <= p.start || match.index >= p.end));
|
||||
if (!overlapped) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -168,6 +169,9 @@ func TestDesktopPackagesPreserveNativePlatformLaunchers(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.HasPrefix(windowsData, []byte{0xef, 0xbb, 0xbf}) {
|
||||
t.Fatal("Windows installer script must have a UTF-8 BOM so native makensis accepts localized strings")
|
||||
}
|
||||
windows := string(windowsData)
|
||||
for _, want := range []string{
|
||||
`File "/oname=${REASONIX_CLI}" "${REASONIX_CLI}"`,
|
||||
@@ -175,6 +179,8 @@ func TestDesktopPackagesPreserveNativePlatformLaunchers(t *testing.T) {
|
||||
`File "/oname=uninstall.exe" "${ARG_REASONIX_SIGNED_UNINSTALLER}"`,
|
||||
`StrCpy $R9 "$INSTDIR\versions\.installer-v${INFO_PRODUCTVERSION}-$R8"`,
|
||||
`File "/oname=${REASONIX_LAYOUT_INSTALLER}" "${REASONIX_GUARD}"`,
|
||||
`nsExec::ExecToLog /OEM`,
|
||||
`Reasonix layout activator output:`,
|
||||
`--activate-staging "$R9" --no-relaunch`,
|
||||
`CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "" "$INSTDIR\${REASONIX_LAUNCHER}" 0`,
|
||||
`CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "" "$INSTDIR\${REASONIX_LAUNCHER}" 0`,
|
||||
@@ -189,6 +195,9 @@ func TestDesktopPackagesPreserveNativePlatformLaunchers(t *testing.T) {
|
||||
strings.Contains(windows, `SetOutPath "$INSTDIR\versions\v${INFO_PRODUCTVERSION}"`) {
|
||||
t.Fatal("normal Windows installer must not write the live version or current.json in place")
|
||||
}
|
||||
if strings.Contains(windows, `ExecWait '"$PLUGINSDIR\${REASONIX_LAYOUT_INSTALLER}"`) {
|
||||
t.Fatal("Windows installer must not discard layout activator stdout/stderr")
|
||||
}
|
||||
if strings.Contains(windows, `CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "" "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" 0`) ||
|
||||
strings.Contains(windows, `CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "" "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" 0`) {
|
||||
t.Fatal("Windows shortcut icon must not point into a version directory that retention removes")
|
||||
|
||||
@@ -135,6 +135,7 @@ func main() {
|
||||
// Observe previous run for crash diagnostics only. Startup tracking must
|
||||
// never force Safe Mode, disable plugins, or select a previous binary.
|
||||
app.previousRun = repair.NewStartupTracker("").ObservePreviousRun()
|
||||
capturePendingUpdateHealthIdentity(app)
|
||||
}
|
||||
|
||||
// Restore saved window size, or fall back to the default.
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/repair"
|
||||
)
|
||||
|
||||
type shutdownSnapshotController struct {
|
||||
@@ -99,6 +100,58 @@ func TestShutdownRecordsLKGOnlyAfterReady(t *testing.T) {
|
||||
a.shutdown(context.Background())
|
||||
}
|
||||
|
||||
func TestCaptureAndCommitPendingUpdateHealthUsesExactStartupIdentity(t *testing.T) {
|
||||
originalRead := readPendingUpdateForHealth
|
||||
originalMark := markPendingUpdateHealthyAfterReady
|
||||
t.Cleanup(func() {
|
||||
readPendingUpdateForHealth = originalRead
|
||||
markPendingUpdateHealthyAfterReady = originalMark
|
||||
})
|
||||
tx := &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: version,
|
||||
CreatedAt: "2026-08-05T00:00:00Z",
|
||||
Platform: "darwin/arm64",
|
||||
TargetKind: "app-bundle",
|
||||
TargetPath: "/Applications/Reasonix.app",
|
||||
BackupPath: "/Applications/Reasonix.app.reasonix-update-backup",
|
||||
}
|
||||
readPendingUpdateForHealth = func() (*repair.UpdateTransaction, error) { return tx, nil }
|
||||
app := NewApp()
|
||||
capturePendingUpdateHealthIdentity(app)
|
||||
wantID := repair.UpdateTransactionID(tx)
|
||||
if app.healthyUpdateCreatedAt != tx.CreatedAt || app.healthyUpdateTransactionID != wantID {
|
||||
t.Fatalf("captured health identity=(%q,%q), want (%q,%q)", app.healthyUpdateCreatedAt, app.healthyUpdateTransactionID, tx.CreatedAt, wantID)
|
||||
}
|
||||
called := false
|
||||
markPendingUpdateHealthyAfterReady = func(running, createdAt, transactionID string) error {
|
||||
called = true
|
||||
if running != version || createdAt != tx.CreatedAt || transactionID != wantID {
|
||||
t.Fatalf("health commit=(%q,%q,%q)", running, createdAt, transactionID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := app.commitPendingUpdateHealth(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("exact startup transaction was not committed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapturePendingUpdateHealthRejectsDifferentTargetVersion(t *testing.T) {
|
||||
originalRead := readPendingUpdateForHealth
|
||||
t.Cleanup(func() { readPendingUpdateForHealth = originalRead })
|
||||
readPendingUpdateForHealth = func() (*repair.UpdateTransaction, error) {
|
||||
return &repair.UpdateTransaction{ToVersion: version + "-other", CreatedAt: "2026-08-05T00:00:00Z"}, nil
|
||||
}
|
||||
app := NewApp()
|
||||
capturePendingUpdateHealthIdentity(app)
|
||||
if app.healthyUpdateCreatedAt != "" || app.healthyUpdateTransactionID != "" {
|
||||
t.Fatalf("captured unrelated transaction: %+v", app)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShutdownUsesDurableSnapshotBeforeClosingController(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
ctrl := &shutdownSnapshotController{SessionAPI: control.New(control.Options{Label: "shutdown"})}
|
||||
|
||||
+23
-5
@@ -1250,15 +1250,21 @@ func currentInstallDir() string {
|
||||
return filepath.Dir(exe)
|
||||
}
|
||||
|
||||
// archiveSupersededLegacyUpdateAfterReady retires a valid older flat-layout
|
||||
// transaction only after the newer versioned desktop has shown its UI. This
|
||||
// lets a signed recovery installer heal users already blocked by a v1.18-v1.19
|
||||
// health marker without deleting repair state or trusting a foreign install.
|
||||
func archiveSupersededLegacyUpdateAfterReady() (bool, error) {
|
||||
// archiveSupersededPendingUpdateAfterReady retires a transaction only after the
|
||||
// current desktop has shown a usable UI. App-bundle recovery handles interrupted
|
||||
// macOS generations; the versioned-layout branch handles older flat Windows and
|
||||
// Linux transactions.
|
||||
func archiveSupersededPendingUpdateAfterReady() (bool, error) {
|
||||
exe := currentExecutablePath()
|
||||
if exe == "" || version == "" || version == "dev" {
|
||||
return false, nil
|
||||
}
|
||||
if archived, err := repair.ArchiveSupersededPendingAppBundleUpdate(version); err != nil || archived {
|
||||
return archived, err
|
||||
}
|
||||
if runtime.GOOS == "darwin" {
|
||||
return false, nil
|
||||
}
|
||||
root, err := installlayout.ResolveInstallRoot(exe)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -1282,6 +1288,18 @@ func archiveSupersededLegacyUpdateAfterReady() (bool, error) {
|
||||
return repair.ArchiveSupersededPendingFileUpdate(running, root)
|
||||
}
|
||||
|
||||
func capturePendingUpdateHealthIdentity(app *App) {
|
||||
if app == nil {
|
||||
return
|
||||
}
|
||||
tx, err := readPendingUpdateForHealth()
|
||||
if err != nil || tx == nil || strings.TrimSpace(tx.ToVersion) != strings.TrimSpace(version) {
|
||||
return
|
||||
}
|
||||
app.healthyUpdateCreatedAt = tx.CreatedAt
|
||||
app.healthyUpdateTransactionID = repair.UpdateTransactionID(tx)
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -30,8 +30,10 @@ var updaterRequestIDRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$
|
||||
|
||||
var (
|
||||
pendingUpdateExistsForInstall = repair.PendingUpdateExists
|
||||
archiveSupersededPendingUpdateForInstall = archiveSupersededLegacyUpdateAfterReady
|
||||
archiveSupersededPendingUpdateForInstall = archiveSupersededPendingUpdateAfterReady
|
||||
reconcilePendingUpdateForInstall = repair.ReconcilePendingUpdate
|
||||
readPendingUpdateForHealth = repair.ReadPendingUpdate
|
||||
markPendingUpdateHealthyAfterReady = repair.MarkUpdateHealthyExact
|
||||
)
|
||||
|
||||
func validateUpdaterRequest(requestID, selectedChannel, expectedVersion string) (string, string, string, error) {
|
||||
@@ -267,10 +269,10 @@ func (a *App) installUpdateRequest(selectedChannel, expectedVersion, requestID s
|
||||
func (a *App) reconcilePendingUpdateForRequest(requestID string, meta *cachedUpdate) error {
|
||||
if pendingUpdateExistsForInstall() {
|
||||
a.emitProgress(requestID, meta.Channel, meta.Version, "recovering", meta.Size, meta.Size, "")
|
||||
// A user-initiated update proves the versioned desktop reached a usable
|
||||
// UI. Retire a superseded flat-layout transaction here as well as in the
|
||||
// delayed post-DOM health task, so an immediate click never has to fail
|
||||
// once and ask the user to retry.
|
||||
// A user-initiated update proves the desktop reached a usable UI. Retire
|
||||
// an eligible superseded app-bundle or flat-layout transaction here as
|
||||
// well as in the delayed post-DOM health task, so an immediate click never
|
||||
// has to fail once and ask the user to retry.
|
||||
if archived, archiveErr := archiveSupersededPendingUpdateForInstall(); archiveErr != nil {
|
||||
slog.Debug("desktop: superseded update was not eligible for automatic archival", "err", archiveErr)
|
||||
} else if archived {
|
||||
|
||||
+53
-20
@@ -3,6 +3,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -181,14 +182,55 @@ func waitForMacHandoffReady(reader *os.File, timeout time.Duration) error {
|
||||
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 {
|
||||
var response macHandoffReadyResponse
|
||||
if err := json.NewDecoder(io.LimitReader(reader, 64<<10)).Decode(&response); err != nil {
|
||||
return err
|
||||
}
|
||||
if string(buf) != "ready" {
|
||||
return fmt.Errorf("unexpected readiness response")
|
||||
switch response.Status {
|
||||
case "ready":
|
||||
return nil
|
||||
case "error":
|
||||
phase := strings.TrimSpace(response.Phase)
|
||||
if phase == "" {
|
||||
phase = "startup"
|
||||
}
|
||||
detail := strings.TrimSpace(response.Error)
|
||||
if detail == "" {
|
||||
detail = "unknown helper error"
|
||||
}
|
||||
return fmt.Errorf("helper failed during %s: %s", phase, detail)
|
||||
default:
|
||||
return fmt.Errorf("unexpected readiness response %q", response.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type macHandoffReadyResponse struct {
|
||||
Status string `json:"status"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func writeMacHandoffReadyResponse(fd int, response macHandoffReadyResponse) error {
|
||||
if fd == 0 {
|
||||
return nil
|
||||
}
|
||||
ready := os.NewFile(uintptr(fd), "reasonix-update-ready")
|
||||
if ready == nil {
|
||||
return fmt.Errorf("readiness pipe is unavailable")
|
||||
}
|
||||
defer ready.Close()
|
||||
return json.NewEncoder(ready).Encode(response)
|
||||
}
|
||||
|
||||
func reportMacHandoffStartupFailure(cfg macUpdateHandoffConfig, phase string, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
_ = writeMacHandoffReadyResponse(cfg.ReadyFD, macHandoffReadyResponse{
|
||||
Status: "error",
|
||||
Phase: phase,
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// maybeRunMacUpdateHandoff handles the detached self-update child before Wails
|
||||
@@ -260,13 +302,16 @@ func runMacUpdateHandoff(cfg macUpdateHandoffConfig) int {
|
||||
pending, err := readMacUpdateHandoff()
|
||||
if err != nil {
|
||||
logf("cannot read pending update handoff: %v", err)
|
||||
reportMacHandoffStartupFailure(cfg, "read-pending-transaction", err)
|
||||
return 1
|
||||
}
|
||||
if strings.TrimSpace(pending.ToVersion) != cfg.ToVersion ||
|
||||
strings.TrimSpace(pending.CreatedAt) != cfg.CreatedAt ||
|
||||
repair.UpdateTransactionID(pending) != cfg.TransactionID ||
|
||||
pending.HandoffOwnerPID <= 0 {
|
||||
logf("pending update does not match handoff identity")
|
||||
err := fmt.Errorf("pending update does not match handoff identity")
|
||||
logf("%v", err)
|
||||
reportMacHandoffStartupFailure(cfg, "validate-transaction-identity", err)
|
||||
return 1
|
||||
}
|
||||
if err := completeMacHandoffHandshake(cfg); err != nil {
|
||||
@@ -543,23 +588,11 @@ 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()
|
||||
}
|
||||
if proceed == nil {
|
||||
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 {
|
||||
if err := writeMacHandoffReadyResponse(cfg.ReadyFD, macHandoffReadyResponse{Status: "ready"}); err != nil {
|
||||
_ = proceed.Close()
|
||||
return fmt.Errorf("signal readiness: %w", err)
|
||||
}
|
||||
|
||||
@@ -1001,6 +1001,42 @@ func TestMacUpdateHandoffParserRequiresCompletePipePair(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMacUpdateHandoffReadinessSurfacesChildStartupFailure(t *testing.T) {
|
||||
readyReader, readyWriter, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer readyReader.Close()
|
||||
originalRead := readMacUpdateHandoff
|
||||
originalLogPath := macHandoffLogPath
|
||||
readMacUpdateHandoff = func() (*repair.UpdateTransaction, error) {
|
||||
return nil, fmt.Errorf("pending transaction is unreadable")
|
||||
}
|
||||
macHandoffLogPath = func() string { return filepath.Join(t.TempDir(), "update-helper.log") }
|
||||
t.Cleanup(func() {
|
||||
readMacUpdateHandoff = originalRead
|
||||
macHandoffLogPath = originalLogPath
|
||||
})
|
||||
done := make(chan int, 1)
|
||||
go func() {
|
||||
done <- runMacUpdateHandoff(macUpdateHandoffConfig{
|
||||
ToVersion: "v2",
|
||||
CreatedAt: "2026-07-28T00:00:00Z",
|
||||
TransactionID: strings.Repeat("a", 64),
|
||||
ReadyFD: int(readyWriter.Fd()),
|
||||
ProceedFD: int(readyWriter.Fd()) + 1,
|
||||
})
|
||||
}()
|
||||
err = waitForMacHandoffReady(readyReader, time.Second)
|
||||
if err == nil || !strings.Contains(err.Error(), "read-pending-transaction") ||
|
||||
!strings.Contains(err.Error(), "pending transaction is unreadable") {
|
||||
t.Fatalf("readiness error = %v", err)
|
||||
}
|
||||
if code := <-done; code == 0 {
|
||||
t.Fatal("helper startup failure returned success")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMacUpdateHandoffHandshakeWaitsForParentRelease(t *testing.T) {
|
||||
readyReader, readyWriter, err := os.Pipe()
|
||||
if err != nil {
|
||||
|
||||
@@ -118,6 +118,8 @@ func TestWindowsInstallerScriptWaitsBeforeCopyingExecutable(t *testing.T) {
|
||||
`File "/oname=${REASONIX_UPDATE_HELPER}" "${REASONIX_UPDATE_HELPER}"`,
|
||||
`File "/oname=${REASONIX_CLI}" "${REASONIX_CLI}"`,
|
||||
`File "/oname=${REASONIX_LAYOUT_INSTALLER}" "${REASONIX_GUARD}"`,
|
||||
`nsExec::ExecToLog /OEM`,
|
||||
`Reasonix layout activator output:`,
|
||||
`--activate-staging "$R9" --no-relaunch`,
|
||||
`File "/oname=${REASONIX_PAYLOAD_MANIFEST}" "${REASONIX_PAYLOAD_MANIFEST}"`,
|
||||
`File "/oname=${REASONIX_PAYLOAD_SIGNATURE}" "${REASONIX_PAYLOAD_SIGNATURE}"`,
|
||||
|
||||
+19
-3
@@ -36,20 +36,36 @@ InstallRoot/
|
||||
The thin launcher only reads `current.json` and starts the active desktop. It
|
||||
never selects a previous version or enters Safe Mode.
|
||||
|
||||
## Upgrading from 1.18–1.19.1
|
||||
## Upgrading from 1.18–1.19.x
|
||||
|
||||
If an older client is stuck on a pending update or Safe Mode loop:
|
||||
|
||||
1. Download the latest signed installer / package from the official download page.
|
||||
2. Run it once (Windows: double-click; no need to uninstall or delete JSON).
|
||||
3. Compatibility payloads may still include a one-shot binary named
|
||||
2. Install it directly over the current copy (Windows: double-click; macOS:
|
||||
replace `Reasonix.app`). Do not uninstall first: keeping the existing install
|
||||
root lets the compatibility migrator prove which stale transaction it owns.
|
||||
3. Start Reasonix once and confirm **Settings > Updates** shows the installed
|
||||
version before trying another in-app update.
|
||||
4. Compatibility payloads may still include a one-shot binary named
|
||||
`reasonix-guard` that only migrates the flat layout into `current.json` and
|
||||
then deletes itself. That binary is not the old Guard product.
|
||||
|
||||
Do not manually delete `pending-update.json`, locks, or AppData as the recovery
|
||||
procedure.
|
||||
|
||||
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
|
||||
showing only exit code 1.
|
||||
|
||||
## macOS
|
||||
|
||||
macOS keeps LaunchServices launching the Wails app bundle directly. Updates
|
||||
replace the signed `.app` atomically; there is no Guard process.
|
||||
|
||||
After the replacement window becomes visible, Reasonix commits only the exact
|
||||
pending transaction captured before launch. Legacy transactions that lack a
|
||||
backup digest, or whose backup is already gone, are retired automatically only
|
||||
after the running executable is proven to belong to that target bundle. Any
|
||||
surviving unknown backup and the original transaction are archived for recovery;
|
||||
they are not deleted or trusted as an automatic rollback source.
|
||||
+14
-3
@@ -34,18 +34,29 @@ InstallRoot/
|
||||
薄启动器只读取 `current.json` 并启动当前 Desktop,**不会**自动选旧版本或进入
|
||||
安全模式。
|
||||
|
||||
## 从 1.18–1.19.1 升级
|
||||
## 从 1.18–1.19.x 升级
|
||||
|
||||
若旧客户端卡在 pending update 或安全模式循环:
|
||||
|
||||
1. 从官方下载页获取最新签名安装包。
|
||||
2. 直接运行一次(Windows:双击即可;无需卸载或手工删 JSON)。
|
||||
3. 兼容载荷中可能仍带有名为 `reasonix-guard` 的**一次性迁移程序**,它只把旧平铺
|
||||
2. 直接覆盖当前安装(Windows:双击安装;macOS:替换 `Reasonix.app`)。不要先卸载:
|
||||
保留原安装根目录,兼容迁移程序才能证明遗留事务属于哪个安装。
|
||||
3. 启动 Reasonix 一次,确认“设置 > 更新”显示的版本正确,再尝试下一次应用内更新。
|
||||
4. 兼容载荷中可能仍带有名为 `reasonix-guard` 的**一次性迁移程序**,它只把旧平铺
|
||||
布局写成 `current.json` 后自删除,并非旧 Guard 产品。
|
||||
|
||||
正式恢复路径**不要求**手工删除 `pending-update.json`、锁文件或 AppData。
|
||||
|
||||
如果 Windows 安装器显示 `Reasonix layout activation failed`,请展开安装详情并复制
|
||||
`Reasonix layout activator output:` 下方的内容。当前安装器会保留 activator 的具体错误,
|
||||
不再只显示 exit code 1。
|
||||
|
||||
## macOS
|
||||
|
||||
macOS 仍由 LaunchServices 直接启动 Wails App 包;更新原子替换签名 `.app`,无
|
||||
Guard 进程。
|
||||
|
||||
替换后的窗口真正显示后,Reasonix 只会提交启动前捕获的精确 pending 事务。对于缺少
|
||||
备份摘要、或备份已经丢失的旧事务,只有在确认当前运行程序确实属于目标 App 包后才会
|
||||
自动退出该事务;仍存在但身份未知的备份和原事务会被归档保留,不会删除,也不会被当作
|
||||
可信来源自动回滚。
|
||||
@@ -16,6 +16,245 @@ import (
|
||||
)
|
||||
|
||||
var supersededUpdateBeforeArchive = func(string) {}
|
||||
var supersededAppUpdateAfterBackupArchive = func(string) {}
|
||||
|
||||
// ArchiveSupersededPendingAppBundleUpdate retires a legacy macOS transaction
|
||||
// only after a healthy desktop is already running from the transaction's exact
|
||||
// target bundle. It is intentionally limited to the two unrecoverable legacy
|
||||
// shapes: the rollback backup identity was never recorded, or the recorded
|
||||
// backup no longer exists. A surviving backup is content-bound and moved aside;
|
||||
// the original transaction is archived under Reasonix repair state. Neither is
|
||||
// deleted, so support can still inspect or manually recover the old bundle.
|
||||
func ArchiveSupersededPendingAppBundleUpdate(runningVersion string) (bool, error) {
|
||||
tx, err := ReadPendingUpdate()
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
eligible, err := validateSupersededPendingAppBundleUpdate(tx, runningVersion)
|
||||
if err != nil || !eligible {
|
||||
return false, err
|
||||
}
|
||||
expectedID := UpdateTransactionID(tx)
|
||||
|
||||
unlock, err := acquirePendingUpdateLock()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("archive superseded app update: lock transaction: %w", err)
|
||||
}
|
||||
defer unlock()
|
||||
unlocks, err := lockRepairMutations(pendingUpdateTargetPaths(tx)...)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("archive superseded app update: lock bundle paths: %w", err)
|
||||
}
|
||||
defer unlocks()
|
||||
|
||||
current, err := ReadPendingUpdate()
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("archive superseded app update: re-read transaction: %w", err)
|
||||
}
|
||||
eligible, err = validateSupersededPendingAppBundleUpdate(current, runningVersion)
|
||||
if err != nil || !eligible {
|
||||
return false, err
|
||||
}
|
||||
if UpdateTransactionID(current) != expectedID {
|
||||
return false, fmt.Errorf("archive superseded app update: transaction changed while waiting")
|
||||
}
|
||||
tx = current
|
||||
|
||||
targetTreeID, err := repairPlanTreeContentStateID(tx.TargetPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("archive superseded app update: read running bundle: %w", err)
|
||||
}
|
||||
backupArchive, backupTreeID, err := archiveSupersededAppBundleBackup(tx, expectedID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
restoreBackup := func(cause error) error {
|
||||
if backupArchive == "" {
|
||||
return cause
|
||||
}
|
||||
actual, digestErr := repairPlanTreeContentStateID(backupArchive)
|
||||
if digestErr != nil || actual != backupTreeID {
|
||||
return fmt.Errorf("%w; preserved changed backup at %s", cause, backupArchive)
|
||||
}
|
||||
if _, statErr := os.Lstat(tx.BackupPath); statErr == nil {
|
||||
return fmt.Errorf("%w; preserved archived backup at %s because the public path was recreated", cause, backupArchive)
|
||||
} else if !os.IsNotExist(statErr) {
|
||||
return fmt.Errorf("%w; inspect backup restore path: %v", cause, statErr)
|
||||
}
|
||||
if restoreErr := renameRepairNodeNoReplace(backupArchive, tx.BackupPath); restoreErr != nil {
|
||||
return fmt.Errorf("%w; preserved archived backup at %s: %v", cause, backupArchive, restoreErr)
|
||||
}
|
||||
return cause
|
||||
}
|
||||
if backupArchive != "" {
|
||||
if _, statErr := os.Lstat(tx.BackupPath); statErr == nil {
|
||||
return false, restoreBackup(fmt.Errorf("archive superseded app update: rollback backup path was recreated during recovery"))
|
||||
} else if !os.IsNotExist(statErr) {
|
||||
return false, restoreBackup(fmt.Errorf("archive superseded app update: inspect rollback backup after archival: %w", statErr))
|
||||
}
|
||||
}
|
||||
|
||||
supersededAppUpdateAfterBackupArchive(backupArchive)
|
||||
archivePath, err := archiveSupersededPendingMarker(tx, expectedID, "app-bundle")
|
||||
if err != nil {
|
||||
return false, restoreBackup(err)
|
||||
}
|
||||
restoreMarker := func(cause error) error {
|
||||
if restoreErr := renameRepairNodeNoReplace(archivePath, PendingUpdatePath()); restoreErr != nil {
|
||||
cause = fmt.Errorf("%w; preserved moved transaction at %s: %v", cause, archivePath, restoreErr)
|
||||
}
|
||||
return restoreBackup(cause)
|
||||
}
|
||||
actualTarget, err := repairPlanTreeContentStateID(tx.TargetPath)
|
||||
if err != nil || actualTarget != targetTreeID {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("running bundle changed during recovery")
|
||||
}
|
||||
return false, restoreMarker(fmt.Errorf("archive superseded app update: %w", err))
|
||||
}
|
||||
if backupArchive != "" {
|
||||
if _, statErr := os.Lstat(tx.BackupPath); statErr == nil {
|
||||
return false, restoreMarker(fmt.Errorf("archive superseded app update: rollback backup path was recreated before commit"))
|
||||
} else if !os.IsNotExist(statErr) {
|
||||
return false, restoreMarker(fmt.Errorf("archive superseded app update: inspect rollback backup before commit: %w", statErr))
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func validateSupersededPendingAppBundleUpdate(tx *UpdateTransaction, runningVersion string) (bool, error) {
|
||||
if tx == nil || tx.TargetKind != "app-bundle" {
|
||||
return false, nil
|
||||
}
|
||||
platform := strings.TrimSpace(tx.Platform)
|
||||
if slash := strings.IndexByte(platform, '/'); slash >= 0 {
|
||||
platform = platform[:slash]
|
||||
}
|
||||
if platform != runtime.GOOS {
|
||||
return false, fmt.Errorf("archive superseded app update: transaction platform %q does not match %q", tx.Platform, runtime.GOOS)
|
||||
}
|
||||
if err := validateUpdateTransaction(tx); err != nil {
|
||||
return false, fmt.Errorf("archive superseded app update: invalid transaction: %w", err)
|
||||
}
|
||||
// A normal update prepares the transaction before this process shuts down;
|
||||
// its backup is intentionally absent until the detached helper performs the
|
||||
// swap. Never mistake that fresh handoff for a stale missing-backup record.
|
||||
if tx.HandoffOwnerPID == os.Getpid() {
|
||||
return false, nil
|
||||
}
|
||||
running := canonicalSemver(runningVersion)
|
||||
from := canonicalSemver(tx.FromVersion)
|
||||
to := canonicalSemver(tx.ToVersion)
|
||||
if !semver.IsValid(running) || !semver.IsValid(to) {
|
||||
return false, fmt.Errorf("archive superseded app update: invalid running or target version")
|
||||
}
|
||||
if running != from && semver.Compare(running, to) < 0 {
|
||||
return false, fmt.Errorf("archive superseded app update: running version %q is neither the prior release nor at least %q", running, to)
|
||||
}
|
||||
if strings.TrimSpace(tx.BackupTreeID) == "" {
|
||||
return true, nil
|
||||
}
|
||||
if _, err := os.Lstat(tx.BackupPath); os.IsNotExist(err) {
|
||||
return true, nil
|
||||
} else if err != nil {
|
||||
return false, fmt.Errorf("archive superseded app update: inspect rollback backup: %w", err)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func archiveSupersededAppBundleBackup(tx *UpdateTransaction, transactionID string) (string, string, error) {
|
||||
info, err := os.Lstat(tx.BackupPath)
|
||||
if os.IsNotExist(err) {
|
||||
return "", "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("archive superseded app update: inspect rollback backup: %w", err)
|
||||
}
|
||||
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return "", "", fmt.Errorf("archive superseded app update: rollback backup is not a real directory")
|
||||
}
|
||||
treeID, err := repairPlanTreeContentStateID(tx.BackupPath)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("archive superseded app update: read rollback backup: %w", err)
|
||||
}
|
||||
shortID := transactionID
|
||||
if len(shortID) > 16 {
|
||||
shortID = shortID[:16]
|
||||
}
|
||||
base := fmt.Sprintf("%s.reasonix-retired-%s-%s", tx.BackupPath, shortID, time.Now().UTC().Format("20060102T150405.000000000Z"))
|
||||
for attempt := 0; attempt < 16; attempt++ {
|
||||
archive := fmt.Sprintf("%s-%d", base, attempt)
|
||||
if err := renameRepairNodeNoReplace(tx.BackupPath, archive); err != nil {
|
||||
if os.IsExist(err) {
|
||||
continue
|
||||
}
|
||||
return "", "", fmt.Errorf("archive superseded app update: move rollback backup: %w", err)
|
||||
}
|
||||
actual, digestErr := repairPlanTreeContentStateID(archive)
|
||||
if digestErr == nil && actual == treeID {
|
||||
return archive, treeID, nil
|
||||
}
|
||||
cause := fmt.Errorf("archive superseded app update: rollback backup changed during archival")
|
||||
if restoreErr := renameRepairNodeNoReplace(archive, tx.BackupPath); restoreErr != nil {
|
||||
return "", "", fmt.Errorf("%w; preserved moved backup at %s: %v", cause, archive, restoreErr)
|
||||
}
|
||||
return "", "", cause
|
||||
}
|
||||
return "", "", fmt.Errorf("archive superseded app update: cannot allocate rollback backup archive path")
|
||||
}
|
||||
|
||||
func archiveSupersededPendingMarker(tx *UpdateTransaction, transactionID, kind string) (string, error) {
|
||||
pendingPath := PendingUpdatePath()
|
||||
archiveDir := filepath.Join(filepath.Dir(pendingPath), "legacy-updates")
|
||||
if err := os.MkdirAll(archiveDir, 0o700); err != nil {
|
||||
return "", fmt.Errorf("archive superseded update: create archive: %w", err)
|
||||
}
|
||||
if !pathInsideResolvedRoot(filepath.Join(config.MemoryUserDir(), "repair"), archiveDir) {
|
||||
return "", fmt.Errorf("archive superseded update: archive directory resolves outside the repair directory")
|
||||
}
|
||||
shortID := transactionID
|
||||
if len(shortID) > 16 {
|
||||
shortID = shortID[:16]
|
||||
}
|
||||
base := filepath.Join(archiveDir, fmt.Sprintf("%s-%s-%s", time.Now().UTC().Format("20060102T150405.000000000Z"), shortID, kind))
|
||||
for attempt := 0; attempt < 16; attempt++ {
|
||||
archivePath := fmt.Sprintf("%s-%d.json", base, attempt)
|
||||
if err := renameRepairNodeNoReplace(pendingPath, archivePath); err != nil {
|
||||
if os.IsExist(err) {
|
||||
continue
|
||||
}
|
||||
return "", fmt.Errorf("archive superseded update: move transaction: %w", err)
|
||||
}
|
||||
restore := func(cause error) error {
|
||||
if restoreErr := renameRepairNodeNoReplace(archivePath, pendingPath); restoreErr != nil {
|
||||
return fmt.Errorf("%w; preserved moved transaction at %s: %v", cause, archivePath, restoreErr)
|
||||
}
|
||||
return cause
|
||||
}
|
||||
body, readErr := os.ReadFile(archivePath)
|
||||
if readErr != nil {
|
||||
return "", restore(fmt.Errorf("archive superseded update: verify moved transaction: %w", readErr))
|
||||
}
|
||||
var archived UpdateTransaction
|
||||
if unmarshalErr := json.Unmarshal(body, &archived); unmarshalErr != nil {
|
||||
return "", restore(fmt.Errorf("archive superseded update: verify moved transaction: %w", unmarshalErr))
|
||||
}
|
||||
if validateErr := validateUpdateTransaction(&archived); validateErr != nil {
|
||||
return "", restore(fmt.Errorf("archive superseded update: verify moved transaction: %w", validateErr))
|
||||
}
|
||||
if UpdateTransactionID(&archived) != transactionID || UpdateTransactionID(tx) != transactionID {
|
||||
return "", restore(fmt.Errorf("archive superseded update: transaction changed before archival"))
|
||||
}
|
||||
return archivePath, nil
|
||||
}
|
||||
return "", fmt.Errorf("archive superseded update: cannot allocate archive path")
|
||||
}
|
||||
|
||||
// ArchiveSupersededPendingFileUpdate retires a superseded file-update transaction
|
||||
// after the same or a newer versioned installation has started successfully.
|
||||
|
||||
@@ -22,6 +22,213 @@ type supersededUpdateFixture struct {
|
||||
running string
|
||||
}
|
||||
|
||||
type supersededAppUpdateFixture struct {
|
||||
home string
|
||||
target string
|
||||
backup string
|
||||
marker string
|
||||
pendingBody []byte
|
||||
transaction *UpdateTransaction
|
||||
}
|
||||
|
||||
func prepareSupersededAppUpdateFixture(t *testing.T, withBackup, withBackupIdentity bool) supersededAppUpdateFixture {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
root := t.TempDir()
|
||||
if resolved, err := filepath.EvalSymlinks(root); err == nil {
|
||||
root = resolved
|
||||
}
|
||||
target := filepath.Join(root, "Reasonix.app")
|
||||
marker := filepath.Join(target, "Contents", "Resources", "release.txt")
|
||||
executable := filepath.Join(target, "Contents", "MacOS", "reasonix-desktop")
|
||||
for _, path := range []string{marker, executable} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("healthy current app"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
backup := target + ".reasonix-update-backup"
|
||||
if withBackup {
|
||||
backupMarker := filepath.Join(backup, "Contents", "Resources", "release.txt")
|
||||
if err := os.MkdirAll(filepath.Dir(backupMarker), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(backupMarker, []byte("preserved previous app"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
originalExecutable := repairExecutable
|
||||
repairExecutable = func() (string, error) { return executable, nil }
|
||||
t.Cleanup(func() { repairExecutable = originalExecutable })
|
||||
tx := &UpdateTransaction{
|
||||
SchemaVersion: updateTransactionVersion,
|
||||
FromVersion: "v1.19.5",
|
||||
ToVersion: "v1.19.7",
|
||||
Platform: runtime.GOOS + "/amd64",
|
||||
TargetKind: "app-bundle",
|
||||
TargetPath: target,
|
||||
BackupPath: backup,
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
if withBackupIdentity {
|
||||
var err error
|
||||
tx.BackupTreeID, err = repairPlanTreeContentStateID(backup)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := createPendingUpdate(tx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pendingBody, err := os.ReadFile(PendingUpdatePath())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return supersededAppUpdateFixture{
|
||||
home: home, target: target, backup: backup, marker: marker,
|
||||
pendingBody: pendingBody, transaction: tx,
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveSupersededPendingAppBundleUpdatePreservesLegacyBackup(t *testing.T) {
|
||||
fixture := prepareSupersededAppUpdateFixture(t, true, false)
|
||||
archived, err := ArchiveSupersededPendingAppBundleUpdate("v1.19.5")
|
||||
if err != nil || !archived {
|
||||
t.Fatalf("archived=%v err=%v", archived, err)
|
||||
}
|
||||
if _, err := os.Lstat(PendingUpdatePath()); !os.IsNotExist(err) {
|
||||
t.Fatalf("pending transaction survived: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(fixture.backup); !os.IsNotExist(err) {
|
||||
t.Fatalf("public rollback path survived: %v", err)
|
||||
}
|
||||
backups, err := filepath.Glob(fixture.backup + ".reasonix-retired-*")
|
||||
if err != nil || len(backups) != 1 {
|
||||
t.Fatalf("archived backups=%v err=%v", backups, err)
|
||||
}
|
||||
if body, err := os.ReadFile(filepath.Join(backups[0], "Contents", "Resources", "release.txt")); err != nil || string(body) != "preserved previous app" {
|
||||
t.Fatalf("archived backup body=%q err=%v", body, err)
|
||||
}
|
||||
entries, err := os.ReadDir(filepath.Join(fixture.home, "repair", "legacy-updates"))
|
||||
if err != nil || len(entries) != 1 {
|
||||
t.Fatalf("transaction archives=%v err=%v", entries, err)
|
||||
}
|
||||
if body, err := os.ReadFile(fixture.marker); err != nil || string(body) != "healthy current app" {
|
||||
t.Fatalf("running app changed=%q err=%v", body, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveSupersededPendingAppBundleUpdateHealsMissingBackup(t *testing.T) {
|
||||
fixture := prepareSupersededAppUpdateFixture(t, false, false)
|
||||
fixture.transaction.BackupTreeID = strings.Repeat("a", 64)
|
||||
if err := overwritePendingUpdateForTest(fixture.transaction); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archived, err := ArchiveSupersededPendingAppBundleUpdate("v1.19.5")
|
||||
if err != nil || !archived {
|
||||
t.Fatalf("archived=%v err=%v", archived, err)
|
||||
}
|
||||
if _, err := os.Lstat(PendingUpdatePath()); !os.IsNotExist(err) {
|
||||
t.Fatalf("missing-backup transaction survived: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveSupersededPendingAppBundleUpdateLeavesModernRollbackForHealthCommit(t *testing.T) {
|
||||
prepareSupersededAppUpdateFixture(t, true, true)
|
||||
archived, err := ArchiveSupersededPendingAppBundleUpdate("v1.19.7")
|
||||
if err != nil || archived {
|
||||
t.Fatalf("modern transaction archived=%v err=%v", archived, err)
|
||||
}
|
||||
if _, err := ReadPendingUpdate(); err != nil {
|
||||
t.Fatalf("modern pending transaction changed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveSupersededPendingAppBundleUpdateLeavesCurrentProcessHandoff(t *testing.T) {
|
||||
fixture := prepareSupersededAppUpdateFixture(t, false, false)
|
||||
fixture.transaction.HandoffOwnerPID = os.Getpid()
|
||||
fixture.transaction.HandoffStagingPath = filepath.Join(filepath.Dir(fixture.target), "handoff-stage")
|
||||
fixture.transaction.HandoffAppPath = filepath.Join(fixture.transaction.HandoffStagingPath, "Reasonix.app")
|
||||
fixture.transaction.HandoffAppTreeID = strings.Repeat("b", 64)
|
||||
fixture.transaction.HandoffStagingTreeID = strings.Repeat("c", 64)
|
||||
if err := overwritePendingUpdateForTest(fixture.transaction); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archived, err := ArchiveSupersededPendingAppBundleUpdate("v1.19.5")
|
||||
if err != nil || archived {
|
||||
t.Fatalf("current handoff archived=%v err=%v", archived, err)
|
||||
}
|
||||
if _, err := readPendingUpdateUnchecked(); err != nil {
|
||||
t.Fatalf("current handoff transaction changed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveSupersededPendingAppBundleUpdateRejectsUnrelatedOlderVersion(t *testing.T) {
|
||||
fixture := prepareSupersededAppUpdateFixture(t, true, false)
|
||||
archived, err := ArchiveSupersededPendingAppBundleUpdate("v1.19.4")
|
||||
if err == nil || archived {
|
||||
t.Fatalf("older running version archived=%v err=%v", archived, err)
|
||||
}
|
||||
assertPendingTransactionUnchanged(t, fixture.pendingBody)
|
||||
}
|
||||
|
||||
func TestArchiveSupersededPendingAppBundleUpdateRestoresStateOnTargetDrift(t *testing.T) {
|
||||
fixture := prepareSupersededAppUpdateFixture(t, true, false)
|
||||
originalHook := supersededAppUpdateAfterBackupArchive
|
||||
supersededAppUpdateAfterBackupArchive = func(string) {
|
||||
if err := os.WriteFile(fixture.marker, []byte("changed concurrently"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() { supersededAppUpdateAfterBackupArchive = originalHook })
|
||||
archived, err := ArchiveSupersededPendingAppBundleUpdate("v1.19.5")
|
||||
if err == nil || archived {
|
||||
t.Fatalf("drifted target archived=%v err=%v", archived, err)
|
||||
}
|
||||
if _, err := ReadPendingUpdate(); err != nil {
|
||||
t.Fatalf("pending transaction was not restored: %v", err)
|
||||
}
|
||||
if body, err := os.ReadFile(filepath.Join(fixture.backup, "Contents", "Resources", "release.txt")); err != nil || string(body) != "preserved previous app" {
|
||||
t.Fatalf("rollback backup was not restored=%q err=%v", body, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveSupersededPendingAppBundleUpdatePreservesRecreatedBackupPath(t *testing.T) {
|
||||
fixture := prepareSupersededAppUpdateFixture(t, true, false)
|
||||
originalHook := supersededAppUpdateAfterBackupArchive
|
||||
supersededAppUpdateAfterBackupArchive = func(string) {
|
||||
marker := filepath.Join(fixture.backup, "Contents", "Resources", "release.txt")
|
||||
if err := os.MkdirAll(filepath.Dir(marker), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(marker, []byte("concurrently recreated backup"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() { supersededAppUpdateAfterBackupArchive = originalHook })
|
||||
|
||||
archived, err := ArchiveSupersededPendingAppBundleUpdate("v1.19.5")
|
||||
if err == nil || archived {
|
||||
t.Fatalf("recreated backup archived=%v err=%v", archived, err)
|
||||
}
|
||||
if _, err := ReadPendingUpdate(); err != nil {
|
||||
t.Fatalf("pending transaction was not restored: %v", err)
|
||||
}
|
||||
if body, err := os.ReadFile(filepath.Join(fixture.backup, "Contents", "Resources", "release.txt")); err != nil || string(body) != "concurrently recreated backup" {
|
||||
t.Fatalf("recreated public backup changed=%q err=%v", body, err)
|
||||
}
|
||||
backups, err := filepath.Glob(fixture.backup + ".reasonix-retired-*")
|
||||
if err != nil || len(backups) != 1 {
|
||||
t.Fatalf("preserved archived backups=%v err=%v", backups, err)
|
||||
}
|
||||
if body, err := os.ReadFile(filepath.Join(backups[0], "Contents", "Resources", "release.txt")); err != nil || string(body) != "preserved previous app" {
|
||||
t.Fatalf("archived original backup changed=%q err=%v", body, err)
|
||||
}
|
||||
}
|
||||
|
||||
func prepareSupersededUpdateFixture(t *testing.T, pendingVersion, runningVersion string) supersededUpdateFixture {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
|
||||
Reference in new issue
Block a user