Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- Fix macOS HIG compliance: system colors, accessibility labels, theme tokens, localization
- Fix idle ping spin loop caused by exhausted AsyncStream iterator (#618)

## [0.28.0] - 2026-04-07

Expand Down
57 changes: 19 additions & 38 deletions TablePro/Core/Database/ConnectionHealthMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ actor ConnectionHealthMonitor {

private var state: HealthState = .healthy
private var monitoringTask: Task<Void, Never>?
private var wakeUpContinuation: AsyncStream<Void>.Continuation?
private var pingCount: Int = 0
private var lastPingTime: ContinuousClock.Instant?

// MARK: - Initialization

Expand Down Expand Up @@ -92,42 +93,16 @@ actor ConnectionHealthMonitor {

Self.logger.trace("Starting health monitoring for connection \(self.connectionId)")

let (wakeUpStream, continuation) = AsyncStream<Void>.makeStream(bufferingPolicy: .bufferingNewest(1))
self.wakeUpContinuation = continuation

monitoringTask = Task { [weak self] in
guard let self else { return }

let initialDelay = Double.random(in: 0 ... 10)
try? await Task.sleep(for: .seconds(initialDelay))
guard !Task.isCancelled else { return }

// Create the iterator ONCE — reusing it across loop iterations
// prevents buffered yields from causing back-to-back instant pings.
var wakeIterator = wakeUpStream.makeAsyncIterator()

while !Task.isCancelled {
await Task.yield()

// Race between the normal ping interval and an early wake-up signal
await withTaskGroup(of: Bool.self) { group in
group.addTask {
try? await Task.sleep(for: .seconds(Self.pingInterval))
return false // normal timer fired
}
group.addTask {
_ = await wakeIterator.next()
return true // woken up early
}

// Wait for whichever finishes first, cancel the other
if await group.next() != nil {
group.cancelAll()
}
}

try? await Task.sleep(for: .seconds(Self.pingInterval))
guard !Task.isCancelled else { break }

await self.performHealthCheck()
}

Expand All @@ -145,16 +120,6 @@ actor ConnectionHealthMonitor {
monitoringTask = nil
task?.cancel()
await task?.value
wakeUpContinuation?.finish()
wakeUpContinuation = nil
}

/// Triggers an immediate health check, interrupting the normal 30-second sleep.
///
/// Call this after a query failure to get faster feedback on connection health
/// instead of waiting for the next scheduled ping.
func checkNow() {
wakeUpContinuation?.yield()
}

/// Resets the monitor to `.healthy` after the user manually reconnects.
Expand All @@ -178,6 +143,22 @@ actor ConnectionHealthMonitor {
return
}

pingCount += 1
let now = ContinuousClock.now
if let last = lastPingTime {
let interval = (now - last) / .seconds(1)
if interval < 5.0 {
Self.logger.warning(
"Ping #\(self.pingCount) fired only \(String(format: "%.2f", interval))s after previous for \(self.connectionId)"
)
} else {
Self.logger.debug("Ping #\(self.pingCount) for \(self.connectionId) (interval: \(String(format: "%.1f", interval))s)")
}
} else {
Self.logger.debug("First ping (#\(self.pingCount)) for \(self.connectionId)")
}
lastPingTime = now

await transitionTo(.checking)

let isAlive = await pingHandler()
Expand Down
8 changes: 6 additions & 2 deletions TablePro/Core/Database/DatabaseManager+Health.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import TableProPluginKit
extension DatabaseManager {
/// Start health monitoring for a connection
internal func startHealthMonitor(for connectionId: UUID) async {
Self.logger.info("startHealthMonitor called for \(connectionId) (existing monitors: \(self.healthMonitors.count))")
// Stop any existing monitor
await stopHealthMonitor(for: connectionId)

Expand All @@ -30,18 +31,20 @@ extension DatabaseManager {
let maxStale = max(queryTimeout, 300) // At least 5 minutes
if let startTime = await self.queryStartTimes[connectionId],
Date().timeIntervalSince(startTime) < maxStale {
Self.logger.debug("Ping skipped — query in-flight for \(connectionId)")
return true // Query still within expected time
}
// Query appears stuck — fall through to ping
Self.logger.warning("Ping proceeding despite in-flight query (stale after \(maxStale)s) for \(connectionId)")
}
guard let mainDriver = await self.activeSessions[connectionId]?.driver else {
Self.logger.debug("Ping skipped — no active driver for \(connectionId)")
return false
}
do {
_ = try await mainDriver.execute(query: "SELECT 1")
return true
} catch {
Self.logger.debug("Ping failed: \(error.localizedDescription)")
Self.logger.debug("Ping failed for \(connectionId): \(error.localizedDescription)")
return false
}
},
Expand Down Expand Up @@ -149,6 +152,7 @@ extension DatabaseManager {
/// Stop health monitoring for a connection
internal func stopHealthMonitor(for connectionId: UUID) async {
if let monitor = healthMonitors.removeValue(forKey: connectionId) {
Self.logger.info("stopHealthMonitor: stopping monitor for \(connectionId) (remaining: \(self.healthMonitors.count))")
await monitor.stopMonitoring()
}
}
Expand Down
Loading