From b8f80932eda52415ca1ff2a5cd82f6d022424c09 Mon Sep 17 00:00:00 2001 From: tracer Date: Sun, 19 Apr 2026 22:48:41 +0200 Subject: [PATCH] fix: serialize ping state updates --- CHANGELOG.md | 4 ++ Sources/Model/API/PingService.swift | 89 +++++++++++++++++++++-------- Sources/Views/MainView.swift | 4 +- 3 files changed, 72 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d01a69..c712aff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 26.1.8 +- Fixed a crash in `PingService` caused by concurrent mutation of shared ping state from multiple async ping tasks. +- Moved ping state tracking and reboot suppression windows into an actor so ping success/failure handling is serialized safely. + ## 26.1.7 - Added remote reboot support for hosts running KeyHelp API 2.14 or newer. - Added a dedicated `APIv2_14` client and mapped 2.14+ hosts to it instead of treating them as API 2.13. diff --git a/Sources/Model/API/PingService.swift b/Sources/Model/API/PingService.swift index fd8ac0a..9703b35 100644 --- a/Sources/Model/API/PingService.swift +++ b/Sources/Model/API/PingService.swift @@ -2,18 +2,15 @@ import Foundation import UserNotifications enum PingService { - private static var previousPingStates: [String: Bool] = [:] - private static var suppressedUntil: [String: Date] = [:] + private static let stateStore = PingStateStore() - static func suppressChecks(for hostname: String, duration: TimeInterval) { - suppressedUntil[hostname] = Date().addingTimeInterval(duration) + static func suppressChecks(for hostname: String, duration: TimeInterval) async { + await stateStore.suppressChecks(for: hostname, duration: duration) } static func ping(hostname: String, apiKey: String, notificationsEnabled: Bool = true) async -> Bool { - if let suppressedUntil = suppressedUntil[hostname], suppressedUntil > Date() { + if await stateStore.shouldSkipPing(for: hostname) { return false - } else { - suppressedUntil.removeValue(forKey: hostname) } guard let url = URL(string: "https://\(hostname)/api/v2/ping") else { @@ -29,38 +26,32 @@ enum PingService { do { let (data, response) = try await URLSession.shared.data(for: request) if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 { - handlePingFailure(for: hostname, notificationsEnabled: notificationsEnabled) + await handlePingFailure(for: hostname, notificationsEnabled: notificationsEnabled) return false } if let result = try? JSONDecoder().decode([String: String].self, from: data), result["response"] == "pong" { - handlePingSuccess(for: hostname, notificationsEnabled: notificationsEnabled) + await handlePingSuccess(for: hostname, notificationsEnabled: notificationsEnabled) return true } else { - handlePingFailure(for: hostname, notificationsEnabled: notificationsEnabled) + await handlePingFailure(for: hostname, notificationsEnabled: notificationsEnabled) return false } } catch { - handlePingFailure(for: hostname, notificationsEnabled: notificationsEnabled) + await handlePingFailure(for: hostname, notificationsEnabled: notificationsEnabled) return false } } - private static func handlePingSuccess(for hostname: String, notificationsEnabled: Bool) { - let wasPreviouslyDown = previousPingStates[hostname] == false - previousPingStates[hostname] = true - - if wasPreviouslyDown && notificationsEnabled { - sendNotification(title: "Server Online", body: "\(hostname) is now online") + private static func handlePingSuccess(for hostname: String, notificationsEnabled: Bool) async { + if let notification = await stateStore.recordSuccess(for: hostname, notificationsEnabled: notificationsEnabled) { + sendNotification(title: notification.title, body: notification.body) } } - private static func handlePingFailure(for hostname: String, notificationsEnabled: Bool) { - let wasPreviouslyUp = previousPingStates[hostname] != false - previousPingStates[hostname] = false - - if wasPreviouslyUp && notificationsEnabled { - sendNotification(title: "Server Offline", body: "\(hostname) is offline") + private static func handlePingFailure(for hostname: String, notificationsEnabled: Bool) async { + if let notification = await stateStore.recordFailure(for: hostname, notificationsEnabled: notificationsEnabled) { + sendNotification(title: notification.title, body: notification.body) } } @@ -74,3 +65,55 @@ enum PingService { UNUserNotificationCenter.current().add(request) } } + +private actor PingStateStore { + private var previousPingStates: [String: Bool] = [:] + private var suppressedUntil: [String: Date] = [:] + + func suppressChecks(for hostname: String, duration: TimeInterval) { + suppressedUntil[hostname] = Date().addingTimeInterval(duration) + previousPingStates[hostname] = false + } + + func shouldSkipPing(for hostname: String) -> Bool { + if let suppressedUntil = suppressedUntil[hostname], suppressedUntil > Date() { + return true + } + + suppressedUntil.removeValue(forKey: hostname) + return false + } + + func recordSuccess(for hostname: String, notificationsEnabled: Bool) -> PingNotification? { + let wasPreviouslyDown = previousPingStates[hostname] == false + previousPingStates[hostname] = true + + guard wasPreviouslyDown, notificationsEnabled else { + return nil + } + + return PingNotification( + title: "Server Online", + body: "\(hostname) is now online" + ) + } + + func recordFailure(for hostname: String, notificationsEnabled: Bool) -> PingNotification? { + let wasPreviouslyUp = previousPingStates[hostname] != false + previousPingStates[hostname] = false + + guard wasPreviouslyUp, notificationsEnabled else { + return nil + } + + return PingNotification( + title: "Server Offline", + body: "\(hostname) is offline" + ) + } +} + +private struct PingNotification { + let title: String + let body: String +} diff --git a/Sources/Views/MainView.swift b/Sources/Views/MainView.swift index 2addaef..d800d4b 100644 --- a/Sources/Views/MainView.swift +++ b/Sources/Views/MainView.swift @@ -381,14 +381,14 @@ struct MainView: View { api = try await APIFactory.detectAndCreateAPI(baseURL: baseURL, apiKey: apiKey) } try await api.restartServer(apiKey: apiKey) - PingService.suppressChecks(for: server.hostname, duration: 90) + await PingService.suppressChecks(for: server.hostname, duration: 90) return ServerActionFeedback( title: "Reboot Requested", message: "The reboot command was sent to \(server.hostname). The host may become unavailable briefly while it restarts." ) } catch let error as URLError where Self.isExpectedRestartDisconnect(error) { - PingService.suppressChecks(for: server.hostname, duration: 90) + await PingService.suppressChecks(for: server.hostname, duration: 90) return ServerActionFeedback( title: "Reboot Requested", message: "The reboot command appears to have been accepted by \(server.hostname). The connection dropped while the host was going away, which is expected during a reboot."