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 @@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Approving a Safe Mode (Full) confirmation running that table's query in a tab opened from a link meanwhile.
- Restored table tab that never loads when another tab was opened before its window came forward.
- Save writing a table's query into the SQL file whose tab the table was opened over.
- Closing a database entry or connection, or disconnecting, rolling back an open transaction without asking.

## [0.77.2] - 2026-10-05

Expand Down
64 changes: 64 additions & 0 deletions TablePro/Core/Database/DatabaseManager+OpenTransactions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//
// DatabaseManager+OpenTransactions.swift
// TablePro
//

import Foundation
import TableProPluginKit

extension DatabaseManager {
/// A connection running a turn is skipped, because asking it waits for that turn. One that has
/// not answered within a second counts as holding nothing, so a close never hangs on a busy or
/// dead server. An aborted transaction counts: a savepoint taken before the failure can still
/// bring its earlier work back to a commit.
internal func databasesHoldingTransaction(
for connectionId: UUID,
among databases: Set<String>? = nil
) async -> [String] {
guard let session = activeSessions[connectionId] else { return [] }
var candidates: [(database: String, driver: DatabaseDriver)] = []
if let driver = session.driver, !sessionDriverGate.isHeld(browsedGateKey(for: connectionId)) {
candidates.append((session.resolvedBrowseDatabase, driver))
}
for database in sessionLanes.parkedDatabases(for: connectionId).sorted() {
let key = SessionDriverGate.Key(connectionId: connectionId, database: database)
guard let driver = sessionLanes.parkedDriver(for: connectionId, database: database),
!sessionDriverGate.isHeld(key)
else { continue }
candidates.append((database, driver))
}
let reads = candidates
.filter { databases?.contains($0.database) ?? true }
.map { candidate in
(database: candidate.database, state: Task { await Self.boundedTransactionState(of: candidate.driver) })
}
var holding: [String] = []
for read in reads {
let state = await read.state.value
if state == .inTransaction || state == .abortedTransaction {
holding.append(read.database)
}
}
return holding
}

/// A task group is no bound here: it waits for its child, and the read queues behind any
/// statement on the connection's serial queue, which ignores cancellation. The late answer is
/// dropped, never cancelled: a driver's cancel can stop the other work queued on the connection.
private static func boundedTransactionState(of driver: DatabaseDriver) async -> PluginSessionTransactionState {
let gate = ConnectionSingleResumeGate<PluginSessionTransactionState>()
Task {
gate.resume(with: .success(await driver.heldSessionTransactionState()))
}
let deadline = Task.detached {
do {
try await Task.sleep(for: .seconds(1))
} catch {
return
}
gate.resume(with: .success(.unknown))
}
defer { deadline.cancel() }
return (try? await gate.wait()) ?? .unknown
}
}
59 changes: 49 additions & 10 deletions TablePro/Core/Services/Infrastructure/ConnectionCloseAction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import Foundation
internal enum ConnectionCloseAction {
internal enum Decision: Equatable {
case closeImmediately
case confirmEndingTransaction
case confirmUnsavedWork
}

Expand All @@ -40,9 +41,26 @@ internal enum ConnectionCloseAction {
return !targets.isEmpty
}

internal static func decision(hasSession: Bool, hasUnsavedWork: Bool) -> Decision {
guard hasSession, hasUnsavedWork else { return .closeImmediately }
return .confirmUnsavedWork
/// The transaction is asked about before unsaved work, whose Save writes at once: a Cancel on the
/// transaction after it would keep the connection open with that write already made.
internal static func decision(hasSession: Bool, holdsTransaction: Bool, hasUnsavedWork: Bool) -> Decision {
guard hasSession else { return .closeImmediately }
if holdsTransaction { return .confirmEndingTransaction }
return hasUnsavedWork ? .confirmUnsavedWork : .closeImmediately
}

internal static func transactionMessage(for databases: [String]) -> String? {
guard let first = databases.first else { return nil }
guard databases.count > 1 else {
return String(
format: String(localized: "The database “%@” has an open transaction. Closing rolls it back and discards its uncommitted changes."),
first
)
}
return String(
format: String(localized: "These databases have open transactions: %@. Closing rolls them back and discards their uncommitted changes."),
ListFormatter.localizedString(byJoining: databases.map { "“\($0)”" })
)
}

internal static func close(connectionId: UUID) async {
Expand All @@ -51,21 +69,42 @@ internal enum ConnectionCloseAction {
/// reported the connection as safe to close over work nobody had been shown.
let coordinators = WindowManager.shared.coordinators(for: connectionId)
let coordinator = coordinators.first ?? WindowManager.shared.coordinator(for: connectionId)
let decision = decision(
hasSession: coordinator != nil,
hasUnsavedWork: coordinators.contains { $0.hasAnyUnsavedWork() }
/// Read at each decision, not once: the window stays editable while the transaction state
/// and the alert are awaited.
let hasUnsavedWork = {
coordinators.contains { $0.hasAnyUnsavedWork() }
|| (coordinators.isEmpty && coordinator?.hasAnyUnsavedWork() == true)
)
guard decision == .confirmUnsavedWork else {
WindowManager.shared.closeWindow(for: connectionId)
return
}
let holding = await DatabaseManager.shared.databasesHoldingTransaction(for: connectionId)
var decision = decision(
hasSession: coordinator != nil,
holdsTransaction: !holding.isEmpty,
hasUnsavedWork: hasUnsavedWork()
)

/// Shown, then asked. A data-loss alert over a connection the user cannot see names work
/// they have no way to look at before answering. Revealing switches the window to it, so an
/// answer that closes nothing puts the user back where they were: a close that leaves them
/// on another connection, with its entry still in the strip, reads as a switch.
let wasShowing = WindowManager.shared.shownConnection(besides: connectionId)
if decision == .confirmEndingTransaction, let coordinator, let message = transactionMessage(for: holding) {
let confirmed = await AlertHelper.confirmDestructive(
title: String(format: String(localized: "Close the connection “%@”?"), coordinator.connection.name),
message: message,
confirmButton: String(localized: "Close"),
window: reveal(connectionId: connectionId)
)
guard confirmed else {
WindowManager.shared.show(wasShowing, inWindowHosting: connectionId)
return
}
decision = Self.decision(hasSession: true, holdsTransaction: false, hasUnsavedWork: hasUnsavedWork())
}
guard decision == .confirmUnsavedWork else {
WindowManager.shared.closeWindow(for: connectionId)
return
}

let presentingWindow = reveal(connectionId: connectionId)
switch await AlertHelper.confirmSaveChanges(
message: String(localized: "Your changes will be lost if you don't save them."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ internal enum ConnectionDisconnectAction {
connectionName: String,
presentingWindow: NSWindow?
) async {
if let message = confirmationMessage(for: connectionId) {
if let message = await confirmationMessage(for: connectionId) {
let confirmed = await AlertHelper.confirmDestructive(
title: String(format: String(localized: "Disconnect from “%@”?"), connectionName),
message: message,
Expand All @@ -30,7 +30,11 @@ internal enum ConnectionDisconnectAction {

/// Nil means disconnect without asking. Losing work the user cannot get back is worth an alert;
/// ending a session they asked to end is not, which is why a clean connection never sees one.
private static func confirmationMessage(for connectionId: UUID) -> String? {
private static func confirmationMessage(for connectionId: UUID) async -> String? {
let holding = await DatabaseManager.shared.databasesHoldingTransaction(for: connectionId)
if let message = transactionMessage(for: holding) {
return message
}
if MainContentCoordinator.hasUnsavedWork(forConnection: connectionId) {
return String(localized: "Unsaved changes will be lost.")
}
Expand All @@ -39,4 +43,18 @@ internal enum ConnectionDisconnectAction {
}
return nil
}

internal static func transactionMessage(for databases: [String]) -> String? {
guard let first = databases.first else { return nil }
guard databases.count > 1 else {
return String(
format: String(localized: "The database “%@” has an open transaction. Disconnecting rolls it back and discards its uncommitted changes."),
first
)
}
return String(
format: String(localized: "These databases have open transactions: %@. Disconnecting rolls them back and discards their uncommitted changes."),
ListFormatter.localizedString(byJoining: databases.map { "“\($0)”" })
)
}
}
30 changes: 29 additions & 1 deletion TablePro/Core/Services/Infrastructure/WorkspaceCloseAction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,17 @@ internal enum WorkspaceCloseAction {

let coordinators = hostedWorkspaces.compactMap { $0.sessionState?.coordinator }
let coordinator = coordinators.first
let victims = coordinators.flatMap { tabs(in: workspace.container, of: $0) }
/// Where the user was before the alert. Confirming reveals the work at risk, which switches
/// the window to that connection and selects one of the tabs, and an answer that closes
/// nothing has to put all of that back: leaving the user on a connection they did not ask
/// for, with the entry still listed, is a close that reads as a switch.
let wasShowing = WindowManager.shared.shownConnection(besides: workspace.connectionId)
guard await confirmEndingTransaction(in: workspace) else {
Self.logger.info("close cancelled at the transaction prompt container=\(workspace.container, privacy: .public)")
return
}
/// Taken after the transaction prompt: the tabs stay editable while it is answered.
let victims = coordinators.flatMap { tabs(in: workspace.container, of: $0) }
guard let closable = await confirm(victims, across: coordinators, revealing: workspace) else {
WindowManager.shared.show(wasShowing, inWindowHosting: workspace.connectionId)
Self.logger.info("close cancelled at the save prompt container=\(workspace.container, privacy: .public)")
Expand Down Expand Up @@ -226,6 +231,29 @@ internal enum WorkspaceCloseAction {
return closable
}

/// Asked before the unsaved-work prompt, whose Save writes at once, so a Cancel here leaves
/// everything as it was. Nothing is revealed: the alert names the database, and the transaction
/// is not in any one tab. Only a database with its own connection loses a transaction on close.
private static func confirmEndingTransaction(in workspace: WorkspaceID) async -> Bool {
let manager = DatabaseManager.shared
guard let session = manager.session(for: workspace.connectionId),
manager.usesDatabaseLanes(session)
else { return true }
let holding = await manager.databasesHoldingTransaction(
for: workspace.connectionId,
among: [workspace.container]
)
guard !holding.isEmpty else { return true }
return await AlertHelper.confirmDestructive(
title: String(format: String(localized: "Close the database “%@”?"), workspace.container),
message: String(
localized: "This database has an open transaction. Closing it rolls the transaction back and discards its uncommitted changes."
),
confirmButton: String(localized: "Close"),
window: WindowManager.shared.window(for: workspace.connectionId)
)
}

/// Leaves the container before it stops being listed, and only when it is the one being browsed.
/// Every other entry already shows the container it names, and moving the cursor for them would
/// switch the database out from under work the user did not touch.
Expand Down
13 changes: 0 additions & 13 deletions TablePro/Views/Main/MainContentCommandActions+BulkClose.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,19 +107,6 @@ extension MainContentCommandActions {
}
}

/// For the paths that close everything whatever the answer: a window closing, a connection
/// closing, a disconnect. They have nowhere to leave a tab open, so a save that could not take
/// every victim stops them, the way a failed apply of staged ALTERs always did. Answering true
/// on a partial save would close exactly the tabs the save refused.
func confirmDiscardingUnsavedWork(victims: [QueryTab] = []) async -> Bool {
switch await resolveUnsavedWork(in: victims) {
case .cancel:
return false
case .close(let closable):
return closable.isSuperset(of: Set(victims.map(\.id)))
}
}

/// Save cannot reach a tab that is not on screen for grid edits, staged principals or a table
/// draft, so the alert says what will happen to those rather than promising a save it cannot
/// make: they stay open, and everything else closes.
Expand Down
5 changes: 1 addition & 4 deletions TablePro/Views/Main/MainContentCommandActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -731,10 +731,7 @@ final class MainContentCommandActions: ObservableObject {
closeTab(id: selected.id)
return
}
Task {
guard await confirmDiscardingUnsavedWork() else { return }
WindowManager.shared.closeWindow(for: connectionId)
}
Task { await ConnectionCloseAction.close(connectionId: connectionId) }
}

/// The single close primitive. `asBatchSurvivor` is `nil` for a lone close gesture, which lets
Expand Down
8 changes: 7 additions & 1 deletion TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,17 @@ final class MockDatabaseDriver: DatabaseDriver, SchemaSwitchable, @unchecked Sen
}

var sessionTransactionStateToReturn: PluginSessionTransactionState = .unknown
var sessionTransactionStateDelaySeconds: Double = 0
var sessionTransactionStateReadWasCancelled = false
/// libpq reports no transaction state once a check has found the socket closed.
var pingFailureForgetsTransactionState = false

func sessionTransactionState() async -> PluginSessionTransactionState {
sessionTransactionStateToReturn
if sessionTransactionStateDelaySeconds > 0 {
try? await Task.sleep(nanoseconds: UInt64(sessionTransactionStateDelaySeconds * 1_000_000_000))
if Task.isCancelled { sessionTransactionStateReadWasCancelled = true }
}
return sessionTransactionStateToReturn
}

init(connection: DatabaseConnection = TestFixtures.makeConnection()) {
Expand Down
91 changes: 91 additions & 0 deletions TableProTests/Core/Database/SessionLanesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,97 @@ struct SessionLanesTests {

#expect(AppSettingsStorage.shared.loadLastSchema(for: harness.connection.id) == "logs_schema")
}

@Test("An open transaction is reported for the browsed database and for every parked one")
func openTransactionsAreReportedOnEveryLane() async throws {
let harness = makeHarness()
defer { cleanUp(harness) }
try await DatabaseManager.shared.switchDatabase(to: "logs", for: harness.connection.id, persist: false)
let logs = try #require(harness.opener.opened["logs"])
harness.home.sessionTransactionStateToReturn = .inTransaction
logs.sessionTransactionStateToReturn = .abortedTransaction

let holding = await DatabaseManager.shared.databasesHoldingTransaction(for: harness.connection.id)
let parkedOnly = await DatabaseManager.shared.databasesHoldingTransaction(
for: harness.connection.id,
among: ["app"]
)

#expect(holding.sorted() == ["app", "logs"])
#expect(parkedOnly == ["app"])
}

/// A lock or an unknown state loses no work, so neither is worth asking about.
@Test(
"A connection without an open transaction is not reported",
arguments: [PluginSessionTransactionState.idle, .holdsSessionLocks, .unknown]
)
func onlyAnOpenTransactionIsReported(_ state: PluginSessionTransactionState) async throws {
let harness = makeHarness()
defer { cleanUp(harness) }
try await DatabaseManager.shared.switchDatabase(to: "logs", for: harness.connection.id, persist: false)
let logs = try #require(harness.opener.opened["logs"])
harness.home.sessionTransactionStateToReturn = state
logs.sessionTransactionStateToReturn = state

let holding = await DatabaseManager.shared.databasesHoldingTransaction(for: harness.connection.id)

#expect(holding.isEmpty)
}

@Test("A connection running a turn is not asked, because asking would wait for the turn")
func connectionRunningATurnIsSkipped() async throws {
let harness = makeHarness()
defer { cleanUp(harness) }
harness.home.sessionTransactionStateToReturn = .inTransaction
let release = AsyncLatch()
let acquired = AsyncLatch()
let holder = Task { @MainActor in
try await DatabaseManager.shared.sessionDriverGate.withExclusiveAccess(
SessionDriverGate.Key(connectionId: harness.connection.id, database: "app")
) {
acquired.open()
await release.wait()
}
}
await acquired.wait()

let holding = await DatabaseManager.shared.databasesHoldingTransaction(for: harness.connection.id)

release.open()
try await holder.value
#expect(holding.isEmpty)
}

@Test("A connection that does not answer counts as holding nothing once the bound passes")
func silentConnectionIsBounded() async {
let harness = makeHarness()
defer { cleanUp(harness) }
harness.home.sessionTransactionStateToReturn = .inTransaction
harness.home.sessionTransactionStateDelaySeconds = 30
let clock = ContinuousClock()
let started = clock.now

let holding = await DatabaseManager.shared.databasesHoldingTransaction(for: harness.connection.id)

#expect(holding.isEmpty)
#expect(clock.now - started < .seconds(5))
}

/// A driver's cancel can reach past the read: FreeTDS stops every call queued on the connection.
@Test("A read that misses the bound is left to finish, not cancelled")
func lateReadIsNotCancelled() async throws {
let harness = makeHarness()
defer { cleanUp(harness) }
harness.home.sessionTransactionStateToReturn = .inTransaction
harness.home.sessionTransactionStateDelaySeconds = 1.5

let holding = await DatabaseManager.shared.databasesHoldingTransaction(for: harness.connection.id)
try await Task.sleep(for: .seconds(1))

#expect(holding.isEmpty)
#expect(!harness.home.sessionTransactionStateReadWasCancelled)
}
}

@MainActor
Expand Down
Loading
Loading