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:
SivanCola authored and GitHub committed 2026-08-05 20:08:27 +08:00
commit 62f38dfec1
17 files changed
+729 -50

No files matched your search

+35 -6
View File
@@ -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.
+4 -2
View File
@@ -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");
+19 -6
View File
@@ -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) {
+9
View File
@@ -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")
+1
View File
@@ -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.
+53
View File
@@ -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
View File
@@ -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.
+7 -5
View File
@@ -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
View File
@@ -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)
}
+36
View File
@@ -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 {
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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 包后才会
自动退出该事务;仍存在但身份未知的备份和原事务会被归档保留,不会删除,也不会被当作
可信来源自动回滚。
+239
View File
@@ -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.
+207
View File
@@ -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()