Skip to content

fix: add recovery for stuck Sparkle extraction updates - #1862

Closed
lawrencecchen wants to merge 1 commit into
mainfrom
task-sparkle-update-stall-recovery
Closed

lawrencecchen wants to merge 1 commit into
mainfrom
task-sparkle-update-stall-recovery

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Mar 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The Sparkle updater can get permanently stuck at "Preparing: XX%" if the XPC connection to the installer helper drops or macOS Gatekeeper stalls during validation. Previously there was no timeout, no cancel button, and no recovery path for this state.

  • Add 5-minute extraction timeout that transitions to an error state with retry button
  • Add cancel button to the preparing/extracting popover view (matching the downloading view)
  • Clean stale Sparkle installation cache contents on app launch so failed extractions don't persist across restarts
  • Log extraction elapsed time in showReady and showInstallingUpdate, and log applicationTerminated flag

Testing

  • Build verified: xcodebuild -scheme cmux -configuration Debug passes
  • Manual verification: the extraction timeout, cancel button, and stale cache cleanup are straightforward additions following existing patterns (check timeout, download cancel button)
  • No behavioral test is practical for this (requires simulating a Sparkle XPC stall)

Related

  • Observed in dogfooding: cmux NIGHTLY stuck at "Preparing: 11%" for 12+ hours with idle Sparkle helper processes

Summary by cubic

Prevents Sparkle updates from stalling at “Preparing: XX%” by adding a 5‑minute extraction timeout, a cancel action, and startup cache cleanup. This gives users a clear recovery path and avoids failures persisting across restarts.

  • Bug Fixes
    • Add 5‑minute extraction timeout; on timeout, show error with retry.
    • Add Cancel to the extracting popover to abort and return to idle.
    • Clean stale Sparkle Installation cache on app launch.
    • Improve logs (extraction elapsed time, applicationTerminated) and reset timers on state changes.

Written for commit 3b11e26. Summary will update on new commits.

Summary by CodeRabbit

  • New Features

    • Users can now cancel in-progress app updates during the extraction phase using a new Cancel button
    • Added automatic timeout protection (5 minutes) during extraction to prevent the update process from hanging indefinitely
  • Bug Fixes

    • Improved installation reliability by cleaning stale cache data on app startup

The Sparkle updater can get permanently stuck at "Preparing: XX%" if
the XPC connection to the installer helper drops or macOS Gatekeeper
stalls during validation. Previously there was no timeout, no cancel
button, and no recovery path for this state.

- Add 5-minute extraction timeout that transitions to error with retry
- Add cancel button to the preparing/extracting popover view
- Clean stale Sparkle installation cache on app launch
- Log extraction elapsed time and applicationTerminated flag
@vercel

vercel Bot commented Mar 20, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Mar 20, 2026 11:14am

@coderabbitai

coderabbitai Bot commented Mar 20, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

The update system now implements extraction lifecycle management, including cache cleanup, timeout scheduling with automatic error handling, and user-initiated cancellation. A new cleanup method removes stale Sparkle cache, while extraction state transitions are tracked with timing and cancellation support.

Changes

Cohort / File(s) Summary
Cache Cleanup
Sources/Update/UpdateController.swift
Added cleanStaleInstallationCache() method that enumerates and deletes stale items from Sparkle's installation-cache directory, with success/failure logged to UpdateLogStore.
Extraction Lifecycle Management
Sources/Update/UpdateDriver.swift, Sources/Update/UpdateViewModel.swift, Sources/Update/UpdateTiming.swift
Introduced extraction timeout tracking via extractionTimeoutWorkItem and extractionStartDate fields; replaced direct state transitions with beginExtraction(progress:) flow; added timeout scheduling that transitions to error state after 300 seconds; added extraction cancellation support via cancelExtraction() method; updated UpdateState.Extracting with cancel callback property.
Extraction UI & Cancellation
Sources/Update/UpdatePopoverView.swift
Added Cancel button to extraction view with keyboard shortcut; connected button to extracting.cancel() and popover dismissal.

Sequence Diagram

sequenceDiagram
    participant User
    participant UpdateDriver
    participant UpdateLogStore
    participant Timer

    User->>UpdateDriver: showDownloadDidStartExtractingUpdate()
    UpdateDriver->>UpdateDriver: beginExtraction(progress)
    UpdateDriver->>UpdateDriver: recordExtractionStartDate
    UpdateDriver->>UpdateDriver: setState(.extracting(cancel))
    UpdateDriver->>Timer: scheduleExtractionTimeout()
    Note over Timer: Wait 300 seconds

    alt Extraction completes before timeout
        UpdateDriver->>UpdateDriver: setState(.readyToInstall)
        UpdateDriver->>UpdateLogStore: Log extractionElapsed
        Timer->>Timer: Cancel timeout
    else Timeout reached
        Timer->>UpdateDriver: scheduleExtractionTimeout()<br/>still in .extracting
        UpdateDriver->>UpdateLogStore: Log timeout error
        UpdateDriver->>UpdateDriver: setState(.error)
    else User cancels
        User->>UpdateDriver: cancelExtraction()
        UpdateDriver->>UpdateLogStore: Log cancellation
        UpdateDriver->>UpdateDriver: setState(.idle)
        Timer->>Timer: Cancel timeout
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

Poem

🐰✨ A stale cache swept away, fresh timers set to play,
Extraction waits no more than five—zero-zero makes us thrive!
With Cancel buttons shining bright, users take control in flight.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding recovery mechanisms for stuck Sparkle extraction updates with timeout and cancel functionality.
Description check ✅ Passed The description covers most required template sections: summary with what changed and why, testing approach with verification details. Demo video section is not applicable for this backend/logic change; review trigger and checklist are included.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch task-sparkle-update-stall-recovery
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3b11e265b2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +296 to +298
private func cancelExtraction() {
UpdateLogStore.shared.append("extraction cancelled by user")
setState(.idle)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Cancel Sparkle extraction when user taps Cancel

cancelExtraction() only sets the view model back to .idle and never cancels the underlying Sparkle update session, so the extraction can continue in the background. If that session later reaches showReady, this driver still auto-replies .install, which can relaunch unexpectedly after the user explicitly pressed Cancel.

Useful? React with 👍 / 👎.

Comment on lines +274 to +275
self.setState(.error(.init(
error: NSError(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Cancel timed-out extraction before surfacing retry

When the 5-minute timeout fires, the code only swaps UI state to .error but does not abort or invalidate the in-flight Sparkle extraction job. In slow-but-eventually-successful extractions, the stale job can still call showReady and auto-install while the user is already seeing a timeout/retry flow (or has started a retry), causing conflicting update actions.

Useful? React with 👍 / 👎.

error: NSError(
domain: "cmux.update",
code: 2,
userInfo: [NSLocalizedDescriptionKey: String(localized: "update.error.extractionStalled", defaultValue: "The update appears to be stuck. Try checking for updates again.")]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add localization entries for extraction-stalled error

This introduces a new user-visible localization key (update.error.extractionStalled) but there is no corresponding entry in Resources/Localizable.xcstrings (repo search only finds this call site). That makes this error message fall back to English in non-English locales, violating the repo’s requirement that all UI strings be fully localized.

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds recovery mechanisms for a real dogfooding bug where Sparkle's extraction phase gets permanently stuck: a 5-minute extraction timeout with a retry-able error state, a Cancel button in the extraction popover, and on-launch cleanup of stale installation cache. The overall approach is well-structured and follows existing patterns in the codebase, but there is one significant behavioral bug with the new Cancel button.

Key issues found:

  • Cancel button doesn't actually cancel the update (UpdateDriver.swift line 296): cancelExtraction() sets the VM state to .idle, but Sparkle's extraction process has no user-cancellable callback (unlike downloads). The extraction continues in the background, and because showReady(toInstallAndRelaunch:) unconditionally calls reply(.install), Sparkle will proceed to install the update despite the user clicking Cancel — potentially surprising the user with an unexpected app relaunch.

  • Extraction timeout resets on every progress callback (UpdateDriver.swift line 267): scheduleExtractionTimeout() is re-posted inside beginExtraction, which is called on every showExtractionReceivedProgress tick. The 300-second timer therefore restarts from zero on each progress update, making it an idle-timeout rather than an absolute maximum extraction time. This works correctly for the reported stuck-at-X% case but diverges from the documented behavior.

  • Installation cache cleanup has no staleness check (UpdateController.swift line 333): All contents of the Sparkle Installation directory are deleted on every app launch with no age or modification-date guard. Legitimately staged update artifacts placed there during a background update cycle would be silently discarded. A minimum-age filter (e.g., 1 hour) would retain safety for the target scenario while reducing the risk of deleting valid data.

Confidence Score: 2/5

  • Not safe to merge without addressing the Cancel button behavior — it silently allows an install the user explicitly rejected.
  • The timeout and stale-cache cleanup are valuable additions, but the Cancel button during extraction introduces a behavioral regression: clicking Cancel dismisses the UI yet Sparkle continues extracting and will unconditionally trigger an install via the existing reply(.install) in showReady. This is a user-facing correctness issue on a code path that previously had no cancel option, so users will encounter it.
  • Sources/Update/UpdateDriver.swift — specifically cancelExtraction() and showReady(toInstallAndRelaunch:); also Sources/Update/UpdateController.swift for the age-unchecked cache cleanup.

Important Files Changed

Filename Overview
Sources/Update/UpdateDriver.swift Adds extraction timeout and cancel support, but cancelExtraction() only changes UI state — Sparkle's extraction continues running and showReady unconditionally calls reply(.install), causing a silent install after user cancellation. Timeout also resets on every progress tick rather than firing once from start.
Sources/Update/UpdateController.swift Adds cleanStaleInstallationCache() called on every launch, which removes all Sparkle Installation directory contents with no staleness/age check — could delete legitimately staged update data on a relaunch.
Sources/Update/UpdateViewModel.swift Adds cancel closure to Extracting struct and wires it into the cancel() dispatch; Equatable conformance intentionally ignores the closure, consistent with all other cancellable state structs in the file.
Sources/Update/UpdatePopoverView.swift Adds a Cancel button to ExtractingView mirroring DownloadingView and CheckingView; UI change is clean and consistent with existing patterns.
Sources/Update/UpdateTiming.swift Adds extractionTimeoutDuration constant (300s); straightforward addition following existing pattern.

Sequence Diagram

sequenceDiagram
    participant S as Sparkle
    participant D as UpdateDriver
    participant VM as UpdateViewModel
    participant UI as ExtractingView

    S->>D: showDownloadDidStartExtractingUpdate()
    D->>D: beginExtraction(progress: 0)
    D->>VM: state = .extracting(cancel:)
    D->>D: scheduleExtractionTimeout() [T+300s]
    VM->>UI: render ExtractingView + Cancel button

    alt Normal completion
        S->>D: showExtractionReceivedProgress(0.5)
        D->>D: beginExtraction(progress: 0.5) [reschedules timeout]
        S->>D: showReady(toInstallAndRelaunch:)
        D->>D: cancelExtractionTimeout
        D->>S: reply(.install)
        S->>D: showInstallingUpdate(...)
        D->>VM: state = .installing
    else Timeout fires (stuck)
        Note over D: T+300s with no completion
        D->>D: extractionTimeoutWorkItem fires
        D->>VM: state = .error(retry/dismiss)
        UI-->>UI: show error + Retry button
    else User cancels
        UI->>D: extracting.cancel()
        D->>VM: state = .idle ⚠️ UI only
        Note over S,D: Sparkle extraction continues!
        S->>D: showReady(toInstallAndRelaunch:)
        D->>S: reply(.install) ⚠️ unexpected install
        S->>D: showInstallingUpdate(...)
        D->>VM: state = .installing ⚠️
    end
Loading

Last reviewed commit: "fix: add recovery fo..."

Comment on lines +296 to +299
private func cancelExtraction() {
UpdateLogStore.shared.append("extraction cancelled by user")
setState(.idle)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Cancel button doesn't actually stop the Sparkle extraction

cancelExtraction() sets the UI state to .idle, but the underlying Sparkle extraction continues running in the background. Because showReady(toInstallAndRelaunch:) unconditionally calls reply(.install) (line 133), Sparkle will still trigger the install and then call showInstallingUpdate, pushing the state back to .installing — surprising the user who believed they cancelled.

Unlike the download phase (where Sparkle provides a cancellation callback in showDownloadInitiated), the extraction phase has no Sparkle-side cancellation mechanism. To make the cancel genuinely effective, showReady needs to check whether the extraction was cancelled before replying .install:

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)
    }
}

extractionCancelled should be reset to false at the start of a new extraction (beginExtraction) and inside setState (alongside the existing extractionStartDate = nil reset).

Comment on lines +267 to +293
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(
error: NSError(
domain: "cmux.update",
code: 2,
userInfo: [NSLocalizedDescriptionKey: String(localized: "update.error.extractionStalled", defaultValue: "The update appears to be stuck. Try checking for updates again.")]
),
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Timeout resets on every progress callback, making it an idle timeout, not an absolute one

scheduleExtractionTimeout() is called inside beginExtraction(progress:), which is invoked on every showExtractionReceivedProgress callback. Each call cancels the previous work item and posts a fresh 300-second timer. This means the timeout fires only if no progress callbacks arrive for 5 minutes — not 5 minutes from the start of extraction.

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 extractionStartDate, and not rescheduling it on subsequent progress callbacks:

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()
        }
    }
}

Comment on lines +333 to +354
private func cleanStaleInstallationCache() {
guard let bundleIdentifier = Bundle.main.bundleIdentifier else { return }
guard let cachesURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { return }

let installURL = cachesURL
.appendingPathComponent(bundleIdentifier)
.appendingPathComponent("org.sparkle-project.Sparkle")
.appendingPathComponent("Installation")

let fm = FileManager.default
guard let contents = try? fm.contentsOfDirectory(at: installURL, includingPropertiesForKeys: nil),
!contents.isEmpty else { return }

for item in contents {
do {
try fm.removeItem(at: item)
UpdateLogStore.shared.append("cleaned stale installation cache: \(item.lastPathComponent)")
} catch {
UpdateLogStore.shared.append("failed to clean installation cache item \(item.lastPathComponent): \(error)")
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 All Installation cache items are deleted indiscriminately on every launch

cleanStaleInstallationCache() removes everything in the Sparkle Installation directory 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:

let oneHour: TimeInterval = 3600
for item in contents {
    let attrs = try? fm.attributesOfItem(atPath: item.path)
    let modified = attrs?[.modificationDate] as? Date ?? .distantPast
    guard Date().timeIntervalSince(modified) > oneHour else { continue }
    // ... existing remove + log
}

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
Sources/Update/UpdateController.swift (1)

333-354: Consider moving file I/O off the main thread.

This method performs synchronous file enumeration and deletion on the main thread during app startup. While typically fast, a slow disk or many stale files could briefly delay launch. Consider dispatching to a background queue.

That said, if profiling shows negligible impact, this is acceptable as-is given the simplicity and the fact that ensureSparkleInstallationCache() follows synchronously anyway.

♻️ Optional: Async cleanup
 private func cleanStaleInstallationCache() {
     guard let bundleIdentifier = Bundle.main.bundleIdentifier else { return }
     guard let cachesURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { return }

     let installURL = cachesURL
         .appendingPathComponent(bundleIdentifier)
         .appendingPathComponent("org.sparkle-project.Sparkle")
         .appendingPathComponent("Installation")

+    DispatchQueue.global(qos: .utility).async {
         let fm = FileManager.default
         guard let contents = try? fm.contentsOfDirectory(at: installURL, includingPropertiesForKeys: nil),
               !contents.isEmpty else { return }

         for item in contents {
             do {
                 try fm.removeItem(at: item)
                 UpdateLogStore.shared.append("cleaned stale installation cache: \(item.lastPathComponent)")
             } catch {
                 UpdateLogStore.shared.append("failed to clean installation cache item \(item.lastPathComponent): \(error)")
             }
         }
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Update/UpdateController.swift` around lines 333 - 354,
cleanStaleInstallationCache performs synchronous file I/O on the main thread;
move the heavy work to a background queue by dispatching the directory
enumeration and removeItem calls (the body of cleanStaleInstallationCache) onto
a background DispatchQueue (e.g., global(qos:.utility) or a dedicated serial
queue) and only marshal back to the main thread (or a thread-safe logging queue)
when calling UpdateLogStore.shared.append to avoid race/UI issues; keep the same
guards and error handling but wrap them in the dispatched block so callers of
cleanStaleInstallationCache remain non-blocking.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@Sources/Update/UpdateController.swift`:
- Around line 333-354: cleanStaleInstallationCache performs synchronous file I/O
on the main thread; move the heavy work to a background queue by dispatching the
directory enumeration and removeItem calls (the body of
cleanStaleInstallationCache) onto a background DispatchQueue (e.g.,
global(qos:.utility) or a dedicated serial queue) and only marshal back to the
main thread (or a thread-safe logging queue) when calling
UpdateLogStore.shared.append to avoid race/UI issues; keep the same guards and
error handling but wrap them in the dispatched block so callers of
cleanStaleInstallationCache remain non-blocking.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e155efb9-cdae-4f4d-a2dd-978a824ec917

📥 Commits

Reviewing files that changed from the base of the PR and between cc0fc55 and 3b11e26.

📒 Files selected for processing (5)
  • Sources/Update/UpdateController.swift
  • Sources/Update/UpdateDriver.swift
  • Sources/Update/UpdatePopoverView.swift
  • Sources/Update/UpdateTiming.swift
  • Sources/Update/UpdateViewModel.swift

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 5 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Sources/Update/UpdateDriver.swift">

<violation number="1" location="Sources/Update/UpdateDriver.swift:274">
P1: The extraction-timeout path only switches to `.error` UI. Invalidate or ignore the current extraction session before showing retry, otherwise stale extraction callbacks can still drive install during the timeout/retry flow.</violation>

<violation number="2" location="Sources/Update/UpdateDriver.swift:298">
P1: The new extraction cancel action is UI-only (`setState(.idle)`) and does not abort the underlying Sparkle update, so the update may still reach `showReady` and auto-install after the user clicks cancel.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.


private func cancelExtraction() {
UpdateLogStore.shared.append("extraction cancelled by user")
setState(.idle)

@cubic-dev-ai cubic-dev-ai Bot Mar 20, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The new extraction cancel action is UI-only (setState(.idle)) and does not abort the underlying Sparkle update, so the update may still reach showReady and auto-install after the user clicks cancel.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/Update/UpdateDriver.swift, line 298:

<comment>The new extraction cancel action is UI-only (`setState(.idle)`) and does not abort the underlying Sparkle update, so the update may still reach `showReady` and auto-install after the user clicks cancel.</comment>

<file context>
@@ -236,6 +243,61 @@ class UpdateDriver: NSObject, SPUUserDriver {
+
+    private func cancelExtraction() {
+        UpdateLogStore.shared.append("extraction cancelled by user")
+        setState(.idle)
+    }
+
</file context>
Fix with Cubic

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(

@cubic-dev-ai cubic-dev-ai Bot Mar 20, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The extraction-timeout path only switches to .error UI. Invalidate or ignore the current extraction session before showing retry, otherwise stale extraction callbacks can still drive install during the timeout/retry flow.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/Update/UpdateDriver.swift, line 274:

<comment>The extraction-timeout path only switches to `.error` UI. Invalidate or ignore the current extraction session before showing retry, otherwise stale extraction callbacks can still drive install during the timeout/retry flow.</comment>

<file context>
@@ -236,6 +243,61 @@ class UpdateDriver: NSObject, SPUUserDriver {
+            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(
+                error: NSError(
+                    domain: "cmux.update",
</file context>
Fix with Cubic

@lawrencecchen lawrencecchen added the stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening. label Sep 23, 2026
@github-project-automation github-project-automation Bot moved this from Todo to Done in cmux backlog Sep 23, 2026

This branch was successfully deployed

1 active deployment
Preview — 3b11e265 Deployed Mar 20, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants