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 @@ -57,6 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fix `QueryResultRow` equality ignoring cell values, preventing SwiftUI from re-rendering updated rows
- Fix status bar row info text rendering off-center due to duplicate spacer
- Fix `Cmd+Delete` in sidebar search or right sidebar clearing the query editor
- Fix SSH tunnel processes not terminated when closing connection window or quitting the app

## [0.14.1] - 2026-03-06

Expand Down
2 changes: 2 additions & 0 deletions TablePro/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}

func applicationWillTerminate(_ notification: Notification) {
SSHTunnelManager.shared.terminateAllProcessesSync()

// Each MainContentCoordinator observes willTerminateNotification and
// synchronously writes tab state via TabDiskActor.saveSync. No additional
// action needed here — the per-coordinator observers fire before this returns.
Expand Down
17 changes: 17 additions & 0 deletions TablePro/Core/Database/DatabaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ final class DatabaseManager {
}

@ObservationIgnored nonisolated(unsafe) private var sshTunnelObserver: NSObjectProtocol?
@ObservationIgnored nonisolated(unsafe) private var lastWindowCloseObserver: NSObjectProtocol?

private init() {
// Observe SSH tunnel failures
Expand All @@ -87,12 +88,28 @@ final class DatabaseManager {
await self.handleSSHTunnelDied(connectionId: connectionId)
}
}

lastWindowCloseObserver = NotificationCenter.default.addObserver(
forName: .lastWindowDidClose,
object: nil,
queue: .main
) { [weak self] notification in
guard let connectionId = notification.userInfo?["connectionId"] as? UUID else { return }
guard let self else { return }

Task { @MainActor in
await self.disconnectSession(connectionId)
}
}
}

deinit {
if let sshTunnelObserver {
NotificationCenter.default.removeObserver(sshTunnelObserver)
}
if let lastWindowCloseObserver {
NotificationCenter.default.removeObserver(lastWindowCloseObserver)
}
}

// MARK: - Session Management
Expand Down
61 changes: 52 additions & 9 deletions TablePro/Core/SSH/SSHTunnelManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ actor SSHTunnelManager {
private let portRangeStart = 60_000
private let portRangeEnd = 65_000
private var healthCheckTask: Task<Void, Never>?
private static let processRegistry = OSAllocatedUnfairLock(initialState: [UUID: Process]())

private init() {
Task { [weak self] in
Expand Down Expand Up @@ -201,6 +202,7 @@ actor SSHTunnelManager {
createdAt: Date()
)
tunnels[connectionId] = tunnel
Self.processRegistry.withLock { $0[connectionId] = launch.process }

return localPort
}
Expand All @@ -218,16 +220,43 @@ actor SSHTunnelManager {
}

tunnels.removeValue(forKey: connectionId)
Self.processRegistry.withLock { $0.removeValue(forKey: connectionId) }
}

/// Close all SSH tunnels
func closeAllTunnels() async {
for (_, tunnel) in tunnels {
if tunnel.process.isRunning {
tunnel.process.terminate()
let currentTunnels = tunnels
tunnels.removeAll()
Self.processRegistry.withLock { $0.removeAll() }

await withTaskGroup(of: Void.self) { group in
for (_, tunnel) in currentTunnels where tunnel.process.isRunning {
group.addTask {
tunnel.process.terminate()
await self.waitForProcessExit(tunnel.process, timeout: .seconds(3))
}
}
}
}

/// Synchronously terminate all SSH tunnel processes.
/// Called from `applicationWillTerminate` where async is not available.
nonisolated func terminateAllProcessesSync() {
let processes = Self.processRegistry.withLock { dict -> [Process] in
let procs = Array(dict.values)
dict.removeAll()
return procs
}
for process in processes where process.isRunning {
process.terminate()
let deadline = Date().addingTimeInterval(1.0)
while process.isRunning, Date() < deadline {
Thread.sleep(forTimeInterval: 0.05)
}
if process.isRunning {
kill(process.processIdentifier, SIGKILL)
}
}
tunnels.removeAll()
}

/// Check if a tunnel exists for a connection
Expand Down Expand Up @@ -389,12 +418,26 @@ actor SSHTunnelManager {
return scriptPath
}

/// Wait for a Process to exit without blocking the current thread
private func waitForProcessExit(_ process: Process) async {
await withCheckedContinuation { continuation in
process.terminationHandler = { _ in
continuation.resume()
private func waitForProcessExit(_ process: Process, timeout: Duration = .seconds(5)) async {
guard process.isRunning else { return }

await withTaskGroup(of: Void.self) { group in
group.addTask {
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
process.terminationHandler = { _ in
continuation.resume()
}
}
}
group.addTask {
try? await Task.sleep(for: timeout)
}
_ = await group.next()
group.cancelAll()
}

if process.isRunning {
kill(process.processIdentifier, SIGKILL)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,5 @@ extension Notification.Name {
// MARK: - SSH

static let sshTunnelDied = Notification.Name("sshTunnelDied")
static let lastWindowDidClose = Notification.Name("lastWindowDidClose")
}
11 changes: 11 additions & 0 deletions TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,20 @@ internal final class WindowLifecycleMonitor {
return
}

let closedConnectionId = entry.connectionId

if let observer = entry.observer {
NotificationCenter.default.removeObserver(observer)
}
entries.removeValue(forKey: windowId)

let hasRemainingWindows = entries.values.contains { $0.connectionId == closedConnectionId }
if !hasRemainingWindows {
NotificationCenter.default.post(
name: .lastWindowDidClose,
object: nil,
userInfo: ["connectionId": closedConnectionId]
)
}
}
}