fix: serialize ping state updates

This commit is contained in:
2026-04-19 22:48:41 +02:00
parent 11815fb807
commit b8f80932ed
3 changed files with 72 additions and 25 deletions

View File

@@ -1,5 +1,9 @@
# Changelog # 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 ## 26.1.7
- Added remote reboot support for hosts running KeyHelp API 2.14 or newer. - 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. - Added a dedicated `APIv2_14` client and mapped 2.14+ hosts to it instead of treating them as API 2.13.

View File

@@ -2,18 +2,15 @@ import Foundation
import UserNotifications import UserNotifications
enum PingService { enum PingService {
private static var previousPingStates: [String: Bool] = [:] private static let stateStore = PingStateStore()
private static var suppressedUntil: [String: Date] = [:]
static func suppressChecks(for hostname: String, duration: TimeInterval) { static func suppressChecks(for hostname: String, duration: TimeInterval) async {
suppressedUntil[hostname] = Date().addingTimeInterval(duration) await stateStore.suppressChecks(for: hostname, duration: duration)
} }
static func ping(hostname: String, apiKey: String, notificationsEnabled: Bool = true) async -> Bool { 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 return false
} else {
suppressedUntil.removeValue(forKey: hostname)
} }
guard let url = URL(string: "https://\(hostname)/api/v2/ping") else { guard let url = URL(string: "https://\(hostname)/api/v2/ping") else {
@@ -29,38 +26,32 @@ enum PingService {
do { do {
let (data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 { if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 {
handlePingFailure(for: hostname, notificationsEnabled: notificationsEnabled) await handlePingFailure(for: hostname, notificationsEnabled: notificationsEnabled)
return false return false
} }
if let result = try? JSONDecoder().decode([String: String].self, from: data), result["response"] == "pong" { 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 return true
} else { } else {
handlePingFailure(for: hostname, notificationsEnabled: notificationsEnabled) await handlePingFailure(for: hostname, notificationsEnabled: notificationsEnabled)
return false return false
} }
} catch { } catch {
handlePingFailure(for: hostname, notificationsEnabled: notificationsEnabled) await handlePingFailure(for: hostname, notificationsEnabled: notificationsEnabled)
return false return false
} }
} }
private static func handlePingSuccess(for hostname: String, notificationsEnabled: Bool) { private static func handlePingSuccess(for hostname: String, notificationsEnabled: Bool) async {
let wasPreviouslyDown = previousPingStates[hostname] == false if let notification = await stateStore.recordSuccess(for: hostname, notificationsEnabled: notificationsEnabled) {
previousPingStates[hostname] = true sendNotification(title: notification.title, body: notification.body)
if wasPreviouslyDown && notificationsEnabled {
sendNotification(title: "Server Online", body: "\(hostname) is now online")
} }
} }
private static func handlePingFailure(for hostname: String, notificationsEnabled: Bool) { private static func handlePingFailure(for hostname: String, notificationsEnabled: Bool) async {
let wasPreviouslyUp = previousPingStates[hostname] != false if let notification = await stateStore.recordFailure(for: hostname, notificationsEnabled: notificationsEnabled) {
previousPingStates[hostname] = false sendNotification(title: notification.title, body: notification.body)
if wasPreviouslyUp && notificationsEnabled {
sendNotification(title: "Server Offline", body: "\(hostname) is offline")
} }
} }
@@ -74,3 +65,55 @@ enum PingService {
UNUserNotificationCenter.current().add(request) 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
}

View File

@@ -381,14 +381,14 @@ struct MainView: View {
api = try await APIFactory.detectAndCreateAPI(baseURL: baseURL, apiKey: apiKey) api = try await APIFactory.detectAndCreateAPI(baseURL: baseURL, apiKey: apiKey)
} }
try await api.restartServer(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( return ServerActionFeedback(
title: "Reboot Requested", title: "Reboot Requested",
message: "The reboot command was sent to \(server.hostname). The host may become unavailable briefly while it restarts." 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) { } 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( return ServerActionFeedback(
title: "Reboot Requested", 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." 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."