Repository navigation
fix: add recovery for stuck Sparkle extraction updates #1862
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,8 @@ class UpdateDriver: NSObject, SPUUserDriver { | |
| private var pendingCheckTransition: DispatchWorkItem? | ||
| private var checkTimeoutWorkItem: DispatchWorkItem? | ||
| private var lastFeedURLString: String? | ||
| private var extractionTimeoutWorkItem: DispatchWorkItem? | ||
| private var extractionStartDate: Date? | ||
|
|
||
| init(viewModel: UpdateViewModel, hostBundle _: Bundle) { | ||
| self.viewModel = viewModel | ||
|
|
@@ -117,21 +119,23 @@ class UpdateDriver: NSObject, SPUUserDriver { | |
|
|
||
| func showDownloadDidStartExtractingUpdate() { | ||
| UpdateLogStore.shared.append("show extraction started") | ||
| setState(.extracting(.init(progress: 0))) | ||
| beginExtraction(progress: 0) | ||
| } | ||
|
|
||
| func showExtractionReceivedProgress(_ progress: Double) { | ||
| UpdateLogStore.shared.append(String(format: "show extraction progress: %.2f", progress)) | ||
| setState(.extracting(.init(progress: progress))) | ||
| beginExtraction(progress: progress) | ||
| } | ||
|
|
||
| func showReady(toInstallAndRelaunch reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) { | ||
| UpdateLogStore.shared.append("show ready to install") | ||
| let elapsed = extractionStartDate.map { String(format: "%.1fs", Date().timeIntervalSince($0)) } ?? "unknown" | ||
| UpdateLogStore.shared.append("show ready to install (extractionElapsed=\(elapsed))") | ||
| reply(.install) | ||
| } | ||
|
|
||
| func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) { | ||
| UpdateLogStore.shared.append("show installing update") | ||
| let elapsed = extractionStartDate.map { String(format: "%.1fs", Date().timeIntervalSince($0)) } ?? "unknown" | ||
| UpdateLogStore.shared.append("show installing update (appTerminated=\(applicationTerminated), extractionElapsed=\(elapsed))") | ||
| setState(.installing(.init( | ||
| retryTerminatingApplication: retryTerminatingApplication, | ||
| dismiss: { [weak viewModel] in | ||
|
|
@@ -222,6 +226,9 @@ class UpdateDriver: NSObject, SPUUserDriver { | |
| checkTimeoutWorkItem?.cancel() | ||
| checkTimeoutWorkItem = nil | ||
| lastCheckStart = nil | ||
| extractionTimeoutWorkItem?.cancel() | ||
| extractionTimeoutWorkItem = nil | ||
| extractionStartDate = nil | ||
| applyState(newState) | ||
| } | ||
| } | ||
|
|
@@ -236,6 +243,61 @@ class UpdateDriver: NSObject, SPUUserDriver { | |
| DispatchQueue.main.asyncAfter(deadline: .now() + UpdateTiming.checkTimeoutDuration, execute: workItem) | ||
| } | ||
|
|
||
| private func beginExtraction(progress: Double) { | ||
| runOnMain { [weak self] in | ||
| guard let self else { return } | ||
| pendingCheckTransition?.cancel() | ||
| pendingCheckTransition = nil | ||
| checkTimeoutWorkItem?.cancel() | ||
| checkTimeoutWorkItem = nil | ||
| lastCheckStart = nil | ||
| extractionTimeoutWorkItem?.cancel() | ||
| extractionTimeoutWorkItem = nil | ||
| if extractionStartDate == nil { | ||
| extractionStartDate = Date() | ||
| } | ||
| let cancel: () -> Void = { [weak self] in | ||
| self?.cancelExtraction() | ||
| } | ||
| applyState(.extracting(.init(progress: progress, cancel: cancel))) | ||
| scheduleExtractionTimeout() | ||
| } | ||
| } | ||
|
|
||
| private func scheduleExtractionTimeout() { | ||
| extractionTimeoutWorkItem?.cancel() | ||
| let workItem = DispatchWorkItem { [weak self] in | ||
| guard let self else { return } | ||
| guard case .extracting = self.viewModel.state else { return } | ||
| let elapsed = self.extractionStartDate.map { String(format: "%.0fs", Date().timeIntervalSince($0)) } ?? "unknown" | ||
| UpdateLogStore.shared.append("extraction timed out after \(elapsed)") | ||
| self.setState(.error(.init( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: The extraction-timeout path only switches to Prompt for AI agents |
||
| error: NSError( | ||
|
Comment on lines
+274
to
+275
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the 5-minute timeout fires, the code only swaps UI state to Useful? React with 👍 / 👎. |
||
| domain: "cmux.update", | ||
| code: 2, | ||
| userInfo: [NSLocalizedDescriptionKey: String(localized: "update.error.extractionStalled", defaultValue: "The update appears to be stuck. Try checking for updates again.")] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This introduces a new user-visible localization key ( Useful? React with 👍 / 👎. |
||
| ), | ||
| retry: { [weak viewModel = self.viewModel] in | ||
| viewModel?.state = .idle | ||
| DispatchQueue.main.async { | ||
| guard let delegate = NSApp.delegate as? AppDelegate else { return } | ||
| delegate.checkForUpdates(nil) | ||
| } | ||
| }, | ||
| dismiss: { [weak viewModel = self.viewModel] in | ||
| viewModel?.state = .idle | ||
| } | ||
| ))) | ||
| } | ||
| extractionTimeoutWorkItem = workItem | ||
| DispatchQueue.main.asyncAfter(deadline: .now() + UpdateTiming.extractionTimeoutDuration, execute: workItem) | ||
|
Comment on lines
+267
to
+293
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For the exact failure mode described in the PR (XPC drops → progress freezes entirely), this works fine. However, a very slow extraction that emits one callback per 4m59s would never time out, which diverges from the PR description's implied "5-minute extraction timeout." Consider posting the timeout once, keyed off private func beginExtraction(progress: Double) {
runOnMain { [weak self] in
guard let self else { return }
// ... existing cancellations ...
let isFirstCall = extractionStartDate == nil
if isFirstCall {
extractionStartDate = Date()
}
let cancel: () -> Void = { [weak self] in self?.cancelExtraction() }
applyState(.extracting(.init(progress: progress, cancel: cancel)))
if isFirstCall {
scheduleExtractionTimeout()
}
}
} |
||
| } | ||
|
|
||
| private func cancelExtraction() { | ||
| UpdateLogStore.shared.append("extraction cancelled by user") | ||
| setState(.idle) | ||
|
Comment on lines
+296
to
+298
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: The new extraction cancel action is UI-only ( Prompt for AI agents |
||
| } | ||
|
Comment on lines
+296
to
+299
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Unlike the download phase (where Sparkle provides a private var extractionCancelled = false
private func cancelExtraction() {
UpdateLogStore.shared.append("extraction cancelled by user")
extractionCancelled = true
setState(.idle)
}
func showReady(toInstallAndRelaunch reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) {
let elapsed = extractionStartDate.map { String(format: "%.1fs", Date().timeIntervalSince($0)) } ?? "unknown"
UpdateLogStore.shared.append("show ready to install (extractionElapsed=\(elapsed))")
if extractionCancelled {
reply(.dismiss)
} else {
reply(.install)
}
}
|
||
|
|
||
| private func applyState(_ newState: UpdateState) { | ||
| viewModel.state = newState | ||
| UpdateLogStore.shared.append("state -> \(describe(newState))") | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
cleanStaleInstallationCache()removes everything in the SparkleInstallationdirectory unconditionally, with no staleness check (e.g., modification date, file age). If Sparkle ever legitimately places something in that directory during a normal background update flow that involves a relaunch — for example an update staged for silent install on next launch — deleting it on startup would silently break that flow without any user-visible error.Adding a minimum-age guard (e.g., only remove items older than 1 hour) would make this safer while still recovering from the 12-hour stuck extractions the PR targets: