Skip to content

Commit ba9a6c2

Browse files
committed
fix: close SSH tunnels on window close and app termination
1 parent a96443a commit ba9a6c2

6 files changed

Lines changed: 84 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5757
- Fix `QueryResultRow` equality ignoring cell values, preventing SwiftUI from re-rendering updated rows
5858
- Fix status bar row info text rendering off-center due to duplicate spacer
5959
- Fix `Cmd+Delete` in sidebar search or right sidebar clearing the query editor
60+
- Fix SSH tunnel processes not terminated when closing connection window or quitting the app
6061

6162
## [0.14.1] - 2026-03-06
6263

TablePro/AppDelegate.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
804804
}
805805

806806
func applicationWillTerminate(_ notification: Notification) {
807+
SSHTunnelManager.shared.terminateAllProcessesSync()
808+
807809
// Each MainContentCoordinator observes willTerminateNotification and
808810
// synchronously writes tab state via TabDiskActor.saveSync. No additional
809811
// action needed here — the per-coordinator observers fire before this returns.

TablePro/Core/Database/DatabaseManager.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ final class DatabaseManager {
7272
}
7373

7474
@ObservationIgnored nonisolated(unsafe) private var sshTunnelObserver: NSObjectProtocol?
75+
@ObservationIgnored nonisolated(unsafe) private var lastWindowCloseObserver: NSObjectProtocol?
7576

7677
private init() {
7778
// Observe SSH tunnel failures
@@ -87,12 +88,28 @@ final class DatabaseManager {
8788
await self.handleSSHTunnelDied(connectionId: connectionId)
8889
}
8990
}
91+
92+
lastWindowCloseObserver = NotificationCenter.default.addObserver(
93+
forName: .lastWindowDidClose,
94+
object: nil,
95+
queue: .main
96+
) { [weak self] notification in
97+
guard let connectionId = notification.userInfo?["connectionId"] as? UUID else { return }
98+
guard let self else { return }
99+
100+
Task { @MainActor in
101+
await self.disconnectSession(connectionId)
102+
}
103+
}
90104
}
91105

92106
deinit {
93107
if let sshTunnelObserver {
94108
NotificationCenter.default.removeObserver(sshTunnelObserver)
95109
}
110+
if let lastWindowCloseObserver {
111+
NotificationCenter.default.removeObserver(lastWindowCloseObserver)
112+
}
96113
}
97114

98115
// MARK: - Session Management

TablePro/Core/SSH/SSHTunnelManager.swift

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ actor SSHTunnelManager {
6060
private let portRangeStart = 60_000
6161
private let portRangeEnd = 65_000
6262
private var healthCheckTask: Task<Void, Never>?
63+
private static let processRegistry = OSAllocatedUnfairLock(initialState: [UUID: Process]())
6364

6465
private init() {
6566
Task { [weak self] in
@@ -201,6 +202,7 @@ actor SSHTunnelManager {
201202
createdAt: Date()
202203
)
203204
tunnels[connectionId] = tunnel
205+
Self.processRegistry.withLock { $0[connectionId] = launch.process }
204206

205207
return localPort
206208
}
@@ -218,16 +220,43 @@ actor SSHTunnelManager {
218220
}
219221

220222
tunnels.removeValue(forKey: connectionId)
223+
Self.processRegistry.withLock { $0.removeValue(forKey: connectionId) }
221224
}
222225

223226
/// Close all SSH tunnels
224227
func closeAllTunnels() async {
225-
for (_, tunnel) in tunnels {
226-
if tunnel.process.isRunning {
227-
tunnel.process.terminate()
228+
let currentTunnels = tunnels
229+
tunnels.removeAll()
230+
Self.processRegistry.withLock { $0.removeAll() }
231+
232+
await withTaskGroup(of: Void.self) { group in
233+
for (_, tunnel) in currentTunnels where tunnel.process.isRunning {
234+
group.addTask {
235+
tunnel.process.terminate()
236+
await self.waitForProcessExit(tunnel.process, timeout: .seconds(3))
237+
}
238+
}
239+
}
240+
}
241+
242+
/// Synchronously terminate all SSH tunnel processes.
243+
/// Called from `applicationWillTerminate` where async is not available.
244+
nonisolated func terminateAllProcessesSync() {
245+
let processes = Self.processRegistry.withLock { dict -> [Process] in
246+
let procs = Array(dict.values)
247+
dict.removeAll()
248+
return procs
249+
}
250+
for process in processes where process.isRunning {
251+
process.terminate()
252+
let deadline = Date().addingTimeInterval(1.0)
253+
while process.isRunning, Date() < deadline {
254+
Thread.sleep(forTimeInterval: 0.05)
255+
}
256+
if process.isRunning {
257+
kill(process.processIdentifier, SIGKILL)
228258
}
229259
}
230-
tunnels.removeAll()
231260
}
232261

233262
/// Check if a tunnel exists for a connection
@@ -389,12 +418,26 @@ actor SSHTunnelManager {
389418
return scriptPath
390419
}
391420

392-
/// Wait for a Process to exit without blocking the current thread
393-
private func waitForProcessExit(_ process: Process) async {
394-
await withCheckedContinuation { continuation in
395-
process.terminationHandler = { _ in
396-
continuation.resume()
421+
private func waitForProcessExit(_ process: Process, timeout: Duration = .seconds(5)) async {
422+
guard process.isRunning else { return }
423+
424+
await withTaskGroup(of: Void.self) { group in
425+
group.addTask {
426+
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
427+
process.terminationHandler = { _ in
428+
continuation.resume()
429+
}
430+
}
431+
}
432+
group.addTask {
433+
try? await Task.sleep(for: timeout)
397434
}
435+
_ = await group.next()
436+
group.cancelAll()
437+
}
438+
439+
if process.isRunning {
440+
kill(process.processIdentifier, SIGKILL)
398441
}
399442
}
400443

TablePro/Core/Services/Infrastructure/AppNotifications.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,5 @@ extension Notification.Name {
3333
// MARK: - SSH
3434

3535
static let sshTunnelDied = Notification.Name("sshTunnelDied")
36+
static let lastWindowDidClose = Notification.Name("lastWindowDidClose")
3637
}

TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,20 @@ internal final class WindowLifecycleMonitor {
122122
return
123123
}
124124

125+
let closedConnectionId = entry.connectionId
126+
125127
if let observer = entry.observer {
126128
NotificationCenter.default.removeObserver(observer)
127129
}
128130
entries.removeValue(forKey: windowId)
131+
132+
let hasRemainingWindows = entries.values.contains { $0.connectionId == closedConnectionId }
133+
if !hasRemainingWindows {
134+
NotificationCenter.default.post(
135+
name: .lastWindowDidClose,
136+
object: nil,
137+
userInfo: ["connectionId": closedConnectionId]
138+
)
139+
}
129140
}
130141
}

0 commit comments

Comments
 (0)