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 @@ -41,6 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Keychain reads no longer collapse a cancelled Touch ID prompt, a failed biometric auth, or any unknown OSStatus into "not found". The `KeychainResult` enum now distinguishes `.userCancelled`, `.authFailed`, and `.error(OSStatus)` from `.notFound`, and the read paths in connection passwords, SSH profile secrets, AI provider keys, and the license key log each case with its own message. Previously a cancelled prompt looked identical to a missing entry, so the caller would treat the password as gone and silently re-save with an empty string on the next write, producing duplicate keychain entries or a connection saved with a blank password.
- Terminal PTY writes retry on `EINTR` instead of treating any non-positive return as "we're done". A signal mid-write previously truncated the input the user typed; the loop would exit silently and the keystrokes were partially sent. The new path retries on `EINTR`, logs the byte position and errno on any other non-recoverable failure, and reports a return value of zero distinctly so the cause is visible in Console.
- MCP HTTP transport no longer writes an empty body when JSON encoding of the response envelope fails. Five sites in `MCPInboundExchange` and `MCPHttpRequestRouter` previously fell back to `Data()`, sending zero bytes to the client which then saw a protocol violation and either disconnected or hung. The encode-failure paths now log and substitute a static `{"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"internal_error"}}` envelope; the pairing-exchange success path falls back to `internalServerError` with a small JSON error body.
- Closing the last window for a connection no longer flashes "Connection lost" and clears that session's cached schema. The health monitor's reconnect loop previously transitioned to `.failed` when its task was cancelled (clean teardown), and the session-level observer treated `.failed` as a real error: it overwrote `session.status` with the lost-connection alert and called `clearCachedData()`. The `.failed` state was never reachable through any non-cancellation path, so it has been removed from `HealthState` along with the orphaned `resetAfterManualReconnect` reset method that only existed to reset from it. Cancellation now logs cleanly and returns without touching session state.
- Result-grid cells on rows marked for deletion keep their dropdown / date / JSON / blob chevron visible at reduced opacity instead of hiding it, so the cell type is still legible while clearly inactive. Click on the dimmed chevron is a no-op; FK arrow navigation is unchanged. Matches the macOS HIG "disabled appearance" guideline.
- Foreign key navigation from a table with unsaved edits opens the referenced table in a new window tab to preserve the edit buffer. Closing that new tab no longer wipes the original tab's data grid. Previously the new tab's teardown broadcast a connection-scoped event that other coordinators on the same connection received, causing them to release their cell data.
- Tables sidebar refreshes automatically after a successful SQL import; the refresh notification now fires after the success sheet's dismissal animation, so the main window is key when the observer runs (#1114)
Expand Down
24 changes: 6 additions & 18 deletions TablePro/Core/Database/ConnectionHealthMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ extension ConnectionHealthMonitor {
case healthy
case checking
case reconnecting(attempt: Int) // 1-based attempt number
case failed
}
}

Expand Down Expand Up @@ -122,15 +121,6 @@ actor ConnectionHealthMonitor {
await task?.value
}

/// Resets the monitor to `.healthy` after the user manually reconnects.
///
/// Call this when an external reconnection succeeds so the monitor resumes
/// normal periodic pings instead of staying in `.failed` state.
func resetAfterManualReconnect() async {
Self.logger.info("Manual reconnect succeeded, resetting to healthy for connection \(self.connectionId)")
await transitionTo(.healthy)
}

// MARK: - Health Check

/// Performs a single health check cycle.
Expand Down Expand Up @@ -176,9 +166,10 @@ actor ConnectionHealthMonitor {
/// Attempts to reconnect with exponential backoff.
///
/// Uses initial delays of 2s, 4s, 8s, then continues doubling up to a
/// 120-second cap. Transitions to `.failed` only when the task is cancelled
/// (i.e., the monitor is stopped). This ensures the monitor keeps trying
/// indefinitely rather than giving up after a fixed number of attempts.
/// 120-second cap. Loops indefinitely until either a reconnect succeeds
/// (transitions to `.healthy` and returns) or the monitoring task is
/// cancelled (returns without a state transition, since cancellation is
/// clean teardown initiated by `stopMonitoring`).
private func attemptReconnect() async {
var attempt = 0

Expand All @@ -187,7 +178,7 @@ actor ConnectionHealthMonitor {

let delay = backoffDelay(for: attempt)

Self.logger.warning("Reconnect attempt \(attempt) for connection \(self.connectionId) waiting \(delay)s")
Self.logger.warning("Reconnect attempt \(attempt) for connection \(self.connectionId), waiting \(delay)s")
await transitionTo(.reconnecting(attempt: attempt))

try? await Task.sleep(for: .seconds(delay))
Expand All @@ -208,8 +199,7 @@ actor ConnectionHealthMonitor {
Self.logger.warning("Reconnect attempt \(attempt) failed for connection \(self.connectionId)")
}

Self.logger.error("Reconnect cancelled after \(attempt) attempts for connection \(self.connectionId)")
await transitionTo(.failed)
Self.logger.debug("Reconnect loop cancelled after \(attempt) attempts for connection \(self.connectionId)")
}

/// Computes the backoff delay for a given attempt number (1-based).
Expand Down Expand Up @@ -249,8 +239,6 @@ actor ConnectionHealthMonitor {
return .debug
case .reconnecting:
return .default
case .failed:
return .error
}
}
}
9 changes: 1 addition & 8 deletions TablePro/Core/Database/DatabaseManager+Health.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,19 +82,12 @@ extension DatabaseManager {
case .reconnecting(let attempt):
Self.logger.info("Reconnecting session \(id) (attempt \(attempt))")
if case .connecting = self.activeSessions[id]?.status {
// Already .connecting skip redundant write
// Already .connecting, skip redundant write
} else {
self.updateSession(id) { session in
session.status = .connecting
}
}
case .failed:
Self.logger.error(
"Health monitoring failed for session \(id)")
self.updateSession(id) { session in
session.status = .error(String(localized: "Connection lost"))
session.clearCachedData()
}
case .checking:
break // No UI update needed
}
Expand Down
Loading