Skip to content

Commit 2c1e9ff

Browse files
authored
fix: SSH thread safety, data change protection, connection reliability, and UI improvements (#337)
* fix: SSH thread safety, data change protection, connection reliability, and UI improvements * chore: remove known-issues tracking doc from repo * fix: simplify changelog and add vi/zh translations for new strings * fix: address review findings — relay serialization, pagination state, query counter, dialog guard * fix: address CodeRabbit review — hop relay safety, stale index, delete cleanup, duplicates
1 parent 49ade12 commit 2c1e9ff

19 files changed

Lines changed: 13012 additions & 13017 deletions

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Save Changes button in toolbar for committing pending data edits
13+
- Confirmation dialog before deleting a connection
14+
- Confirmation dialog before sort, pagination, filter, or search discards unsaved edits
15+
16+
### Fixed
17+
18+
- SSH tunnel crashes caused by concurrent libssh2 calls on the same session
19+
- Unsaved cell edits lost when switching tabs, sorting, paginating, filtering, or switching apps
20+
- Auto-reconnect and health monitor silently discarding unsaved changes
21+
- SSH tunnel recovery failing after tunnel death due to stale driver state
22+
- Health monitor ping interfering with active user queries
23+
- Connection test not cleaning up SSH tunnel on completion
24+
- Test connection success indicator not resetting after field changes
25+
- SSH port field accepting invalid values
26+
1027
## [0.19.1] - 2026-03-16
1128

1229
### Fixed

TablePro/Core/Database/DatabaseManager.swift

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ final class DatabaseManager {
4141
/// Health monitors for active connections (MySQL/PostgreSQL only)
4242
private var healthMonitors: [UUID: ConnectionHealthMonitor] = [:]
4343

44+
/// Tracks connections with user queries currently in-flight.
45+
/// The health monitor skips pings while a query is running to avoid
46+
/// racing on non-thread-safe driver connections.
47+
private var queriesInFlight: [UUID: Int] = [:]
48+
4449
/// Current session (computed from currentSessionId)
4550
var currentSession: ConnectionSession? {
4651
guard let sessionId = currentSessionId else { return nil }
@@ -311,10 +316,18 @@ final class DatabaseManager {
311316

312317
/// Execute a query on the current session
313318
func execute(query: String) async throws -> QueryResult {
314-
guard let driver = activeDriver else {
319+
guard let sessionId = currentSessionId, let driver = activeDriver else {
315320
throw DatabaseError.notConnected
316321
}
317322

323+
queriesInFlight[sessionId, default: 0] += 1
324+
defer {
325+
if let count = queriesInFlight[sessionId], count > 1 {
326+
queriesInFlight[sessionId] = count - 1
327+
} else {
328+
queriesInFlight.removeValue(forKey: sessionId)
329+
}
330+
}
318331
return try await driver.execute(query: query)
319332
}
320333

@@ -346,17 +359,22 @@ final class DatabaseManager {
346359
sshPasswordOverride: sshPassword
347360
)
348361

349-
defer {
350-
// Close tunnel after test
362+
let result: Bool
363+
do {
364+
let driver = try DatabaseDriverFactory.createDriver(for: testConnection)
365+
result = try await driver.testConnection()
366+
} catch {
351367
if connection.sshConfig.enabled {
352-
Task {
353-
try? await SSHTunnelManager.shared.closeTunnel(connectionId: connection.id)
354-
}
368+
try? await SSHTunnelManager.shared.closeTunnel(connectionId: connection.id)
355369
}
370+
throw error
356371
}
357372

358-
let driver = try DatabaseDriverFactory.createDriver(for: testConnection)
359-
return try await driver.testConnection()
373+
if connection.sshConfig.enabled {
374+
try? await SSHTunnelManager.shared.closeTunnel(connectionId: connection.id)
375+
}
376+
377+
return result
360378
}
361379

362380
// MARK: - SSH Tunnel Helper
@@ -447,6 +465,9 @@ final class DatabaseManager {
447465
connectionId: connectionId,
448466
pingHandler: { [weak self] in
449467
guard let self else { return false }
468+
// Skip ping while a user query is in-flight to avoid racing
469+
// on the same non-thread-safe driver connection.
470+
guard await self.queriesInFlight[connectionId] == nil else { return true }
450471
guard let mainDriver = await self.activeSessions[connectionId]?.driver else {
451472
return false
452473
}
@@ -638,8 +659,14 @@ final class DatabaseManager {
638659

639660
Self.logger.warning("SSH tunnel died for connection: \(session.connection.name)")
640661

641-
// Mark connection as reconnecting
662+
// Stop health monitor before retrying to prevent stale pings during reconnect
663+
await stopHealthMonitor(for: connectionId)
664+
665+
// Disconnect the stale driver and invalidate it so connectToSession
666+
// creates a fresh connection instead of short-circuiting on driver != nil
667+
session.driver?.disconnect()
642668
updateSession(connectionId) { session in
669+
session.driver = nil
643670
session.status = .connecting
644671
}
645672

0 commit comments

Comments
 (0)