cmux/Sources/Update/UpdateDelegate.swift
Lawrence Chen 9af7df0dac Fix socket accept loop not restarted after Sparkle update relaunch
After a Sparkle auto-update relaunches cmux, the control socket stops
accepting connections because start() early-returns when isRunning is
true, without checking if the accept loop thread is actually alive.

- Add acceptLoopAlive flag to track accept loop thread liveness
- Fix start() early-return to also check acceptLoopAlive, so a dead
  thread triggers full socket re-creation
- Break acceptLoop() after 50 consecutive accept() failures with 10ms
  backoff instead of tight-spinning forever
- Clean up socket in applicationWillTerminate and
  updaterWillRelaunchApplication for clean teardown before relaunch
2026-02-17 22:00:47 -08:00

113 lines
4.8 KiB
Swift

import Sparkle
import Cocoa
enum UpdateChannelSettings {
static let includeNightlyBuildsKey = "cmux.includeNightlyBuilds"
static let defaultIncludeNightlyBuilds = false
static let stableFeedURL = "https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml"
static let nightlyFeedURL = "https://github.com/manaflow-ai/cmux/releases/download/nightly/appcast.xml"
static func resolvedFeedURLString(
infoFeedURL: String?,
defaults: UserDefaults = .standard
) -> (url: String, isNightly: Bool, usedFallback: Bool) {
let stableURL = (infoFeedURL?.isEmpty == false) ? infoFeedURL! : stableFeedURL
let includeNightlyBuilds = defaults.bool(forKey: includeNightlyBuildsKey)
if includeNightlyBuilds {
return (url: nightlyFeedURL, isNightly: true, usedFallback: false)
}
return (url: stableURL, isNightly: false, usedFallback: stableURL == stableFeedURL)
}
}
extension UpdateDriver: SPUUpdaterDelegate {
func feedURLString(for updater: SPUUpdater) -> String? {
#if DEBUG
let env = ProcessInfo.processInfo.environment
if let override = env["CMUX_UI_TEST_FEED_URL"], !override.isEmpty {
UpdateTestURLProtocol.registerIfNeeded()
recordFeedURLString(override, usedFallback: false)
return override
}
#endif
let infoURL = Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") as? String
let resolved = UpdateChannelSettings.resolvedFeedURLString(infoFeedURL: infoURL)
UpdateLogStore.shared.append("update channel: \(resolved.isNightly ? "nightly" : "stable")")
recordFeedURLString(resolved.url, usedFallback: resolved.usedFallback)
return resolved.url
}
/// Called when an update is scheduled to install silently,
/// which occurs when automatic download is enabled.
func updater(_ updater: SPUUpdater, willInstallUpdateOnQuit item: SUAppcastItem, immediateInstallationBlock immediateInstallHandler: @escaping () -> Void) -> Bool {
viewModel.state = .installing(.init(
isAutoUpdate: true,
retryTerminatingApplication: immediateInstallHandler,
dismiss: { [weak viewModel] in
viewModel?.state = .idle
}
))
return true
}
func updater(_ updater: SPUUpdater, didFinishLoading appcast: SUAppcast) {
let count = appcast.items.count
let firstVersion = appcast.items.first?.displayVersionString ?? ""
if firstVersion.isEmpty {
UpdateLogStore.shared.append("appcast loaded (items=\(count))")
} else {
UpdateLogStore.shared.append("appcast loaded (items=\(count), first=\(firstVersion))")
}
}
func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) {
let version = item.displayVersionString
let fileURL = item.fileURL?.absoluteString ?? ""
if fileURL.isEmpty {
UpdateLogStore.shared.append("valid update found: \(version)")
} else {
UpdateLogStore.shared.append("valid update found: \(version) (\(fileURL))")
}
}
func updaterDidNotFindUpdate(_ updater: SPUUpdater, error: Error) {
let nsError = error as NSError
let reasonValue = (nsError.userInfo[SPUNoUpdateFoundReasonKey] as? NSNumber)?.intValue
let reason = reasonValue.map { SPUNoUpdateFoundReason(rawValue: OSStatus($0)) } ?? nil
let reasonText = reason.map(describeNoUpdateFoundReason) ?? "unknown"
let userInitiated = (nsError.userInfo[SPUNoUpdateFoundUserInitiatedKey] as? NSNumber)?.boolValue ?? false
let latestItem = nsError.userInfo[SPULatestAppcastItemFoundKey] as? SUAppcastItem
let latestVersion = latestItem?.displayVersionString ?? ""
if latestVersion.isEmpty {
UpdateLogStore.shared.append("no update found (reason=\(reasonText), userInitiated=\(userInitiated))")
} else {
UpdateLogStore.shared.append("no update found (reason=\(reasonText), userInitiated=\(userInitiated), latest=\(latestVersion))")
}
}
func updaterWillRelaunchApplication(_ updater: SPUUpdater) {
TerminalController.shared.stop()
NSApp.invalidateRestorableState()
for window in NSApp.windows {
window.invalidateRestorableState()
}
}
}
private func describeNoUpdateFoundReason(_ reason: SPUNoUpdateFoundReason) -> String {
switch reason {
case .unknown:
return "unknown"
case .onLatestVersion:
return "onLatestVersion"
case .onNewerThanLatestVersion:
return "onNewerThanLatestVersion"
case .systemIsTooOld:
return "systemIsTooOld"
case .systemIsTooNew:
return "systemIsTooNew"
@unknown default:
return "unknown"
}
}