connection updates

This commit is contained in:
Mark Gregory
2026-10-19 11:10:42 +01:00
parent 3c685eece7
commit bbc35ab4e3
2 changed files with 82 additions and 32 deletions
+3 -2
View File
@@ -683,6 +683,7 @@ final class DatabaseModel: ObservableObject {
saveActiveQuery() saveActiveQuery()
if let oldID = selectedID { NativeMySQLRunner.disconnect(profileID: oldID) } if let oldID = selectedID { NativeMySQLRunner.disconnect(profileID: oldID) }
result = QueryResult() result = QueryResult()
isRunning = false
selectedID = id selectedID = id
UserDefaults.standard.set(id.uuidString, forKey: "DBSyn.selectedConnectionID") UserDefaults.standard.set(id.uuidString, forKey: "DBSyn.selectedConnectionID")
selectedDatabase = profile.database selectedDatabase = profile.database
@@ -887,7 +888,7 @@ final class DatabaseModel: ObservableObject {
introspect(database: usedSchema) introspect(database: usedSchema)
} }
} }
guard selectedID == profile.id else { isRunning = false; return } guard selectedID == profile.id else { return }
if listingDatabases { if listingDatabases {
databases = lastResult.rows.compactMap { $0.first } databases = lastResult.rows.compactMap { $0.first }
status = "Connected to \(profile.name)" status = "Connected to \(profile.name)"
@@ -914,7 +915,7 @@ final class DatabaseModel: ObservableObject {
if !listingDatabases { if !listingDatabases {
queryDurationText = String(format: "%.3f s", Date().timeIntervalSince(queryStarted)) queryDurationText = String(format: "%.3f s", Date().timeIntervalSince(queryStarted))
} }
isRunning = false if selectedID == profile.id { isRunning = false }
} }
} }
+79 -30
View File
@@ -437,40 +437,78 @@ private final class PersistentMySQLSessions {
let connection: MySQLWireConnection let connection: MySQLWireConnection
} }
private let queue = DispatchQueue(label: "DBSyn.MySQLSessions") // Each profile has its own serial executor. An unreachable server cannot
private var connections: [UUID: Entry] = [:] // block requests to any other saved connection.
private final class SessionSlot {
let queue: DispatchQueue
var entry: Entry?
// Only accessed on queue except generation, which is lock protected.
private let generationLock = NSLock()
private var currentGeneration: UInt64 = 0
init(id: UUID) {
queue = DispatchQueue(label: "DBSyn.MySQLSession.\(id.uuidString)")
}
func generation() -> UInt64 {
generationLock.lock(); defer { generationLock.unlock() }
return currentGeneration
}
func invalidate() {
generationLock.lock(); currentGeneration &+= 1; generationLock.unlock()
}
}
private let slotsLock = NSLock()
private var slots: [UUID: SessionSlot] = [:]
private func slot(for id: UUID) -> SessionSlot {
slotsLock.lock(); defer { slotsLock.unlock() }
if let existing = slots[id] { return existing }
let created = SessionSlot(id: id)
slots[id] = created
return created
}
func execute(profile: ConnectionProfile, database: String, func execute(profile: ConnectionProfile, database: String,
password: String, sql: String) throws -> QueryResult { password: String, sql: String) throws -> QueryResult {
try queue.sync { let session = slot(for: profile.id)
// A changed connection profile or password requires a new session. let expectedGeneration = session.generation()
if let previous = connections[profile.id], return try session.queue.sync {
(previous.profile != profile || previous.password != password) { guard session.generation() == expectedGeneration else {
connections.removeValue(forKey: profile.id) throw MySQLWireError(message: "Connection request superseded")
} }
if let previous = session.entry,
if connections[profile.id] == nil { (previous.profile != profile || previous.password != password) {
session.entry = nil
DBSynSSHTunnels.shared.stop(profile.id)
}
if session.entry == nil {
let endpoint = try DBSynSSHTunnels.shared.endpoint(for: profile) let endpoint = try DBSynSSHTunnels.shared.endpoint(for: profile)
let connection = try MySQLWireConnection(host: endpoint.0, port: endpoint.1, requireTLS: !profile.useSSHTunnel && profile.useTLS && !["127.0.0.1", "localhost", "::1"].contains(endpoint.0.lowercased())) let connection = try MySQLWireConnection(
host: endpoint.0, port: endpoint.1,
requireTLS: !profile.useSSHTunnel && profile.useTLS &&
!["127.0.0.1", "localhost", "::1"].contains(endpoint.0.lowercased())
)
connection.markHost(endpoint.0) connection.markHost(endpoint.0)
try connection.authenticate(user: profile.username, try connection.authenticate(user: profile.username,
password: password, database: database) password: password, database: database)
connections[profile.id] = Entry(profile: profile, guard session.generation() == expectedGeneration else {
password: password, throw MySQLWireError(message: "Connection request superseded")
connection: connection) }
session.entry = Entry(profile: profile, password: password,
connection: connection)
} }
guard let entry = session.entry else {
guard let entry = connections[profile.id] else {
throw MySQLWireError(message: "MySQL session was not created") throw MySQLWireError(message: "MySQL session was not created")
} }
do { do {
// Crucially, no new authentication takes place here. USE, let result = try entry.connection.query(sql)
// session variables and transactions survive between queries. guard session.generation() == expectedGeneration else {
return try entry.connection.query(sql) throw MySQLWireError(message: "Connection request superseded")
}
return result
} catch { } catch {
// The wire protocol can be out of sync after a failed query. session.entry = nil
// Discard it rather than silently issuing commands on a bad socket.
connections.removeValue(forKey: profile.id)
throw error throw error
} }
} }
@@ -479,23 +517,36 @@ private final class PersistentMySQLSessions {
func stream(profile: ConnectionProfile, database: String, func stream(profile: ConnectionProfile, database: String,
password: String, sql: String, password: String, sql: String,
onRow: (String) throws -> Void) throws -> Int64 { onRow: (String) throws -> Void) throws -> Int64 {
try queue.sync { let session = slot(for: profile.id)
guard let entry = connections[profile.id], let expectedGeneration = session.generation()
return try session.queue.sync {
guard session.generation() == expectedGeneration,
let entry = session.entry,
entry.profile == profile, entry.password == password else { entry.profile == profile, entry.password == password else {
throw MySQLWireError(message: "Export session is no longer connected") throw MySQLWireError(message: "Export session is no longer connected")
} }
do { do {
return try entry.connection.streamFirstColumn(sql, onRow: onRow) return try entry.connection.streamFirstColumn(sql, onRow: { row in
guard session.generation() == expectedGeneration else {
throw MySQLWireError(message: "Export cancelled: connection switched")
}
try onRow(row)
})
} catch { } catch {
connections.removeValue(forKey: profile.id) session.entry = nil
throw error throw error
} }
} }
} }
func disconnect(profileID: UUID) { func disconnect(profileID: UUID) {
queue.sync { _ = connections.removeValue(forKey: profileID) } let session = slot(for: profileID)
DBSynSSHTunnels.shared.stop(profileID) // Invalidate immediately without waiting for a blocked network read.
session.invalidate()
session.queue.async {
session.entry = nil
DBSynSSHTunnels.shared.stop(profileID)
}
} }
} }
@@ -526,8 +577,6 @@ enum NativeMySQLRunner {
// Connection shutdown may wait behind a slow network operation on the // Connection shutdown may wait behind a slow network operation on the
// session's serial queue. Never make the SwiftUI main thread wait for it. // session's serial queue. Never make the SwiftUI main thread wait for it.
static func disconnect(profileID: UUID) { static func disconnect(profileID: UUID) {
Task.detached(priority: .utility) { PersistentMySQLSessions.shared.disconnect(profileID: profileID)
PersistentMySQLSessions.shared.disconnect(profileID: profileID)
}
} }
} }