diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ba82d97..b9ddcbe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/TablePro/Core/Database/ConnectionHealthMonitor.swift b/TablePro/Core/Database/ConnectionHealthMonitor.swift index 109f53cf..d9f12b8c 100644 --- a/TablePro/Core/Database/ConnectionHealthMonitor.swift +++ b/TablePro/Core/Database/ConnectionHealthMonitor.swift @@ -48,7 +48,8 @@ actor ConnectionHealthMonitor { private var state: HealthState = .healthy private var monitoringTask: Task? - private var wakeUpContinuation: AsyncStream.Continuation? + private var pingCount: Int = 0 + private var lastPingTime: ContinuousClock.Instant? // MARK: - Initialization @@ -92,9 +93,6 @@ actor ConnectionHealthMonitor { Self.logger.trace("Starting health monitoring for connection \(self.connectionId)") - let (wakeUpStream, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) - self.wakeUpContinuation = continuation - monitoringTask = Task { [weak self] in guard let self else { return } @@ -102,32 +100,9 @@ actor ConnectionHealthMonitor { 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() } @@ -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. @@ -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() diff --git a/TablePro/Core/Database/DatabaseManager+Health.swift b/TablePro/Core/Database/DatabaseManager+Health.swift index 6592b2ff..94e5badf 100644 --- a/TablePro/Core/Database/DatabaseManager+Health.swift +++ b/TablePro/Core/Database/DatabaseManager+Health.swift @@ -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) @@ -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 } }, @@ -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() } }