From 1cd842dd924bf114b096f222851c47d2e36ad4d9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 25 Mar 2026 14:57:53 -0700 Subject: [PATCH 1/3] Fix update attempt refreshing pill without actually updating The attemptUpdate() subscriber watched for .updateAvailable state to auto-confirm, but showUpdateFound used setStateAfterMinimumCheckDelay which delays the transition by up to 2 seconds. During that window, dismissUpdateInstallation (from a background probe race) could cancel the pending transition, reverting state to idle without ever confirming. The subscriber then tore down on the transient idle, silently abandoning the update. Fix: move auto-confirm to the Sparkle driver level via an autoInstallOnNextUpdate flag. When set, showUpdateFound immediately calls reply(.install) bypassing the delay entirely. The subscriber is kept as a fallback but no longer tears down on transient idle while the flag is active. Closes https://github.com/manaflow-ai/cmux/issues/2166 --- Sources/Update/UpdateController.swift | 13 +++++++++++++ Sources/Update/UpdateDriver.swift | 15 +++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/Sources/Update/UpdateController.swift b/Sources/Update/UpdateController.swift index c5fd4ad0ece9..623988b30d26 100644 --- a/Sources/Update/UpdateController.swift +++ b/Sources/Update/UpdateController.swift @@ -177,6 +177,7 @@ class UpdateController { func attemptUpdate() { stopAttemptUpdateMonitoring() didObserveAttemptUpdateProgress = false + userDriver.autoInstallOnNextUpdate = true attemptInstallCancellable = viewModel.$state .receive(on: DispatchQueue.main) @@ -187,6 +188,9 @@ class UpdateController { self.didObserveAttemptUpdateProgress = true } + // Fallback: auto-confirm if we reach .updateAvailable + // (e.g. the driver flag was cleared by a dismiss before + // showUpdateFound fired). if case .updateAvailable = state { UpdateLogStore.shared.append("attemptUpdate auto-confirming available update") state.confirm() @@ -196,6 +200,14 @@ class UpdateController { guard self.didObserveAttemptUpdateProgress, !state.isInstallable else { return } + + // While the driver's auto-install flag is set, the Sparkle + // check may still be starting up. Don't tear down on a + // transient .idle that occurs during retry/probe races. + if state.isIdle, self.userDriver.autoInstallOnNextUpdate { + return + } + self.stopAttemptUpdateMonitoring() } @@ -279,6 +291,7 @@ class UpdateController { attemptInstallCancellable?.cancel() attemptInstallCancellable = nil didObserveAttemptUpdateProgress = false + userDriver.autoInstallOnNextUpdate = false } private func installNoUpdateDismissObserver() { diff --git a/Sources/Update/UpdateDriver.swift b/Sources/Update/UpdateDriver.swift index 289df890d98f..3e74fbc2398b 100644 --- a/Sources/Update/UpdateDriver.swift +++ b/Sources/Update/UpdateDriver.swift @@ -10,6 +10,12 @@ class UpdateDriver: NSObject, SPUUserDriver { private var checkTimeoutWorkItem: DispatchWorkItem? private var lastFeedURLString: String? + /// When true, the next update found by Sparkle is confirmed immediately + /// without waiting for the minimum-check-display delay. This prevents + /// the delayed `.updateAvailable` transition from being preempted by + /// a `dismissUpdateInstallation` call (e.g. from a background probe race). + var autoInstallOnNextUpdate: Bool = false + init(viewModel: UpdateViewModel, hostBundle _: Bundle) { self.viewModel = viewModel super.init() @@ -44,6 +50,12 @@ class UpdateDriver: NSObject, SPUUserDriver { state: SPUUserUpdateState, reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) { UpdateLogStore.shared.append("show update found: \(appcastItem.displayVersionString)") + if autoInstallOnNextUpdate { + autoInstallOnNextUpdate = false + UpdateLogStore.shared.append("auto-installing update (attemptUpdate)") + reply(.install) + return + } setStateAfterMinimumCheckDelay(.updateAvailable(.init(appcastItem: appcastItem, reply: reply))) } @@ -57,12 +69,14 @@ class UpdateDriver: NSObject, SPUUserDriver { func showUpdateNotFoundWithError(_ error: any Error, acknowledgement: @escaping () -> Void) { + autoInstallOnNextUpdate = false UpdateLogStore.shared.append("show update not found: \(formatErrorForLog(error))") setStateAfterMinimumCheckDelay(.notFound(.init(acknowledgement: acknowledgement))) } func showUpdaterError(_ error: any Error, acknowledgement: @escaping () -> Void) { + autoInstallOnNextUpdate = false let details = formatErrorForLog(error) UpdateLogStore.shared.append("show updater error: \(details)") setState(.error(.init( @@ -151,6 +165,7 @@ class UpdateDriver: NSObject, SPUUserDriver { } func dismissUpdateInstallation() { + autoInstallOnNextUpdate = false UpdateLogStore.shared.append("dismiss update installation") if case .error = viewModel.state { UpdateLogStore.shared.append("dismiss update installation ignored (error visible)") From e450afe1ef0d3e27504eccdee7f2a429bc7f51b0 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 25 Mar 2026 15:16:55 -0700 Subject: [PATCH 2/3] Revert "Fix update attempt refreshing pill without actually updating" This reverts commit 1cd842dd924bf114b096f222851c47d2e36ad4d9. --- Sources/Update/UpdateController.swift | 13 ------------- Sources/Update/UpdateDriver.swift | 15 --------------- 2 files changed, 28 deletions(-) diff --git a/Sources/Update/UpdateController.swift b/Sources/Update/UpdateController.swift index 623988b30d26..c5fd4ad0ece9 100644 --- a/Sources/Update/UpdateController.swift +++ b/Sources/Update/UpdateController.swift @@ -177,7 +177,6 @@ class UpdateController { func attemptUpdate() { stopAttemptUpdateMonitoring() didObserveAttemptUpdateProgress = false - userDriver.autoInstallOnNextUpdate = true attemptInstallCancellable = viewModel.$state .receive(on: DispatchQueue.main) @@ -188,9 +187,6 @@ class UpdateController { self.didObserveAttemptUpdateProgress = true } - // Fallback: auto-confirm if we reach .updateAvailable - // (e.g. the driver flag was cleared by a dismiss before - // showUpdateFound fired). if case .updateAvailable = state { UpdateLogStore.shared.append("attemptUpdate auto-confirming available update") state.confirm() @@ -200,14 +196,6 @@ class UpdateController { guard self.didObserveAttemptUpdateProgress, !state.isInstallable else { return } - - // While the driver's auto-install flag is set, the Sparkle - // check may still be starting up. Don't tear down on a - // transient .idle that occurs during retry/probe races. - if state.isIdle, self.userDriver.autoInstallOnNextUpdate { - return - } - self.stopAttemptUpdateMonitoring() } @@ -291,7 +279,6 @@ class UpdateController { attemptInstallCancellable?.cancel() attemptInstallCancellable = nil didObserveAttemptUpdateProgress = false - userDriver.autoInstallOnNextUpdate = false } private func installNoUpdateDismissObserver() { diff --git a/Sources/Update/UpdateDriver.swift b/Sources/Update/UpdateDriver.swift index 3e74fbc2398b..289df890d98f 100644 --- a/Sources/Update/UpdateDriver.swift +++ b/Sources/Update/UpdateDriver.swift @@ -10,12 +10,6 @@ class UpdateDriver: NSObject, SPUUserDriver { private var checkTimeoutWorkItem: DispatchWorkItem? private var lastFeedURLString: String? - /// When true, the next update found by Sparkle is confirmed immediately - /// without waiting for the minimum-check-display delay. This prevents - /// the delayed `.updateAvailable` transition from being preempted by - /// a `dismissUpdateInstallation` call (e.g. from a background probe race). - var autoInstallOnNextUpdate: Bool = false - init(viewModel: UpdateViewModel, hostBundle _: Bundle) { self.viewModel = viewModel super.init() @@ -50,12 +44,6 @@ class UpdateDriver: NSObject, SPUUserDriver { state: SPUUserUpdateState, reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) { UpdateLogStore.shared.append("show update found: \(appcastItem.displayVersionString)") - if autoInstallOnNextUpdate { - autoInstallOnNextUpdate = false - UpdateLogStore.shared.append("auto-installing update (attemptUpdate)") - reply(.install) - return - } setStateAfterMinimumCheckDelay(.updateAvailable(.init(appcastItem: appcastItem, reply: reply))) } @@ -69,14 +57,12 @@ class UpdateDriver: NSObject, SPUUserDriver { func showUpdateNotFoundWithError(_ error: any Error, acknowledgement: @escaping () -> Void) { - autoInstallOnNextUpdate = false UpdateLogStore.shared.append("show update not found: \(formatErrorForLog(error))") setStateAfterMinimumCheckDelay(.notFound(.init(acknowledgement: acknowledgement))) } func showUpdaterError(_ error: any Error, acknowledgement: @escaping () -> Void) { - autoInstallOnNextUpdate = false let details = formatErrorForLog(error) UpdateLogStore.shared.append("show updater error: \(details)") setState(.error(.init( @@ -165,7 +151,6 @@ class UpdateDriver: NSObject, SPUUserDriver { } func dismissUpdateInstallation() { - autoInstallOnNextUpdate = false UpdateLogStore.shared.append("dismiss update installation") if case .error = viewModel.state { UpdateLogStore.shared.append("dismiss update installation ignored (error visible)") From ad5e5911782d16aa83296a5400de32bce0eeec1d Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 25 Mar 2026 15:17:18 -0700 Subject: [PATCH 3/3] Fix update attempt refreshing pill without actually updating The attemptUpdate() subscriber tore down monitoring whenever it saw .idle after observing progress. During check startup (retry loop, background probe race), state can transiently return to .idle before Sparkle's interactive check begins. The subscriber interpreted this as "check completed" and stopped monitoring, so the auto-confirm for .updateAvailable never fired. Fix: add !state.isIdle to the teardown guard so monitoring only stops on terminal failures (.notFound, .error), not transient idle. Closes https://github.com/manaflow-ai/cmux/issues/2166 --- Sources/Update/UpdateController.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/Update/UpdateController.swift b/Sources/Update/UpdateController.swift index c5fd4ad0ece9..4fb5b31d56ce 100644 --- a/Sources/Update/UpdateController.swift +++ b/Sources/Update/UpdateController.swift @@ -193,7 +193,10 @@ class UpdateController { return } - guard self.didObserveAttemptUpdateProgress, !state.isInstallable else { + // Only stop on terminal failure states (.notFound, .error). + // Don't stop on .idle — the check may still be starting up + // (e.g. retry loop, background probe finishing). + guard self.didObserveAttemptUpdateProgress, !state.isInstallable, !state.isIdle else { return } self.stopAttemptUpdateMonitoring()