From f0ab89ed54e93ebe70cbf9570b42f69cd251a120 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Aug 2026 14:48:35 -0700 Subject: [PATCH] fix(macos): drain SSH tunnels before app quit (#130421) --- .../OpenClaw/ApplicationRelocator.swift | 6 +-- .../macos/Sources/OpenClaw/DebugActions.swift | 23 ++++------ .../Sources/OpenClaw/GeneralSettings.swift | 2 +- apps/macos/Sources/OpenClaw/MenuBar.swift | 44 ++++++++----------- .../OpenClaw/RemoteTunnelManager.swift | 12 ++++- .../Sources/OpenClaw/StatusMenuRenderer.swift | 2 +- .../OpenClaw/TerminationSignalWatcher.swift | 5 +-- .../RemoteTunnelManagerTests.swift | 19 ++++++++ 8 files changed, 63 insertions(+), 50 deletions(-) create mode 100644 apps/macos/Tests/OpenClawIPCTests/RemoteTunnelManagerTests.swift diff --git a/apps/macos/Sources/OpenClaw/ApplicationRelocator.swift b/apps/macos/Sources/OpenClaw/ApplicationRelocator.swift index c777d51c705e..eabf122f684d 100644 --- a/apps/macos/Sources/OpenClaw/ApplicationRelocator.swift +++ b/apps/macos/Sources/OpenClaw/ApplicationRelocator.swift @@ -1014,7 +1014,7 @@ extension ApplicationRelocator { do { try helper.run() TerminationSignalWatcher.scheduleExitFailsafe() - NSApp.terminate(nil) + AppDelegate.requestTermination() return .terminating } catch { self.logger.error("Could not schedule relaunch: \(error.localizedDescription, privacy: .public)") @@ -1111,7 +1111,7 @@ extension ApplicationRelocator { } self.cancelSupervisorRestorationWatcher() TerminationSignalWatcher.scheduleExitFailsafe() - NSApp.terminate(nil) + AppDelegate.requestTermination() } return .scheduled } @@ -1171,7 +1171,7 @@ extension ApplicationRelocator { \(supervisor.label, privacy: .public) can restart the installed app. """) TerminationSignalWatcher.scheduleExitFailsafe() - NSApp.terminate(nil) + AppDelegate.requestTermination() } } diff --git a/apps/macos/Sources/OpenClaw/DebugActions.swift b/apps/macos/Sources/OpenClaw/DebugActions.swift index 8d0ffab346c8..e27233d9bee5 100644 --- a/apps/macos/Sources/OpenClaw/DebugActions.swift +++ b/apps/macos/Sources/OpenClaw/DebugActions.swift @@ -174,21 +174,16 @@ enum DebugActions { static func restartApp() { let url = Bundle.main.bundleURL let task = Process() - // Relaunch shortly after this instance exits so we get a true restart even in debug. - task.launchPath = "/bin/sh" - if let profile = AppProfile.current.name { - task.arguments = [ - "-c", - "sleep 0.2; open -n --env OPENCLAW_PROFILE=\"$2\" \"$1\"", - "_", - url.path, - profile, - ] - } else { - task.arguments = ["-c", "sleep 0.2; open -n \"$1\"", "_", url.path] - } + // The replacement must wait until cleanup releases this profile's instance lock. + task.executableURL = URL(fileURLWithPath: "/bin/sh") + task.arguments = [ + "-c", + "while /bin/kill -0 \"$1\" 2>/dev/null; do /bin/sleep 0.1; done; shift; exec /usr/bin/open -n \"$@\"", + "openclaw-restart", + String(ProcessInfo.processInfo.processIdentifier), + ] + (AppProfile.current.name.map { ["--env", "OPENCLAW_PROFILE=\($0)"] } ?? []) + [url.path] try? task.run() - NSApp.terminate(nil) + AppDelegate.requestTermination() } @MainActor diff --git a/apps/macos/Sources/OpenClaw/GeneralSettings.swift b/apps/macos/Sources/OpenClaw/GeneralSettings.swift index 9edc20494f09..e47da157d57c 100644 --- a/apps/macos/Sources/OpenClaw/GeneralSettings.swift +++ b/apps/macos/Sources/OpenClaw/GeneralSettings.swift @@ -253,7 +253,7 @@ struct GeneralSettings: View { .foregroundStyle(.secondary) } Spacer(minLength: 18) - Button("Quit") { NSApp.terminate(nil) } + Button("Quit") { AppDelegate.requestTermination() } .buttonStyle(.bordered) .controlSize(.small) } diff --git a/apps/macos/Sources/OpenClaw/MenuBar.swift b/apps/macos/Sources/OpenClaw/MenuBar.swift index 55dd69b40a08..99acab5ce75c 100644 --- a/apps/macos/Sources/OpenClaw/MenuBar.swift +++ b/apps/macos/Sources/OpenClaw/MenuBar.swift @@ -154,26 +154,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var terminationCleanupFinished = false private var profileInstanceLock: AppInstanceLock? private let webChatAutoLogger = Logger(subsystem: "ai.openclaw", category: "Chat") - var nodeTerminationCleanup: @MainActor () async -> Void = { - // CUA shutdown drains the worker before closing the daemon socket; run it - // first so other cleanup cannot consume the app termination deadline. + private static func cleanUpProcesses() async { + // Start tunnel retirement before helper drains can consume the quit deadline. + async let tunnelCleanup: Void = RemoteTunnelManager.shared.shutdown() + async let gatewayCleanup: Void = GatewayConnection.shared.shutdown() + async let profileCleanup: Void = MacGatewayConnectionFleet.shared.shutdown() + // CUA must drain its worker before the node closes the daemon socket. if AppLaunchRuntimePlan.current.allowsCuaComputerControl { await CuaDriverHostCoordinator.shared.shutdown() } await TalkMLXSpeechSynthesizer.shared.shutdown() await MacNodeModeCoordinator.shared.stopAndWait() - } - - var peekabooBridgeTerminationCleanup: @MainActor () async -> Void = { - await PeekabooBridgeHostCoordinator.shared.shutdown() - } - - var waitForTerminationCleanupDeadline: @MainActor () async -> Void = { - try? await Task.sleep(for: .seconds(AppTerminationTiming.cleanupDeadlineSeconds)) - } - - var applicationTerminationReply: @MainActor (NSApplication, Bool) -> Void = { app, allow in - app.reply(toApplicationShouldTerminate: allow) + _ = await (tunnelCleanup, gatewayCleanup, profileCleanup) } var openDashboardAction: @MainActor () -> Void = { AppNavigationActions.openDashboard() } @@ -431,9 +423,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { VoiceWakeGlobalSettingsSync.shared.stop() DashboardManager.shared.close() WebChatManager.shared.close() - WebChatManager.shared.resetTunnels() - Task { await RemoteTunnelManager.shared.stopAll() } - Task { await GatewayConnection.shared.shutdown() } + } + + static func requestTermination() { + // terminateLater spins a nested AppKit loop. Calling terminate on the main + // dispatch queue prevents that loop from running MainActor cleanup or its deadline. + NSApp.perform(#selector(NSApplication.terminate(_:)), with: nil, afterDelay: 0, inModes: [.common]) } func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { @@ -443,17 +438,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { guard self.terminationCleanupTask == nil else { return .terminateLater } - let nodeCleanup = self.nodeTerminationCleanup - let bridgeCleanup = self.peekabooBridgeTerminationCleanup self.terminationCleanupTask = Task { @MainActor [weak self] in - async let nodeCleanupResult: Void = nodeCleanup() - async let bridgeCleanupResult: Void = bridgeCleanup() - _ = await (nodeCleanupResult, bridgeCleanupResult) + async let processCleanupResult: Void = Self.cleanUpProcesses() + async let bridgeCleanupResult: Void = PeekabooBridgeHostCoordinator.shared.shutdown() + _ = await (processCleanupResult, bridgeCleanupResult) self?.finishTerminationCleanup(for: sender) } - let waitForDeadline = self.waitForTerminationCleanupDeadline self.terminationDeadlineTask = Task { @MainActor [weak self] in - await waitForDeadline() + try? await Task.sleep(for: .seconds(AppTerminationTiming.cleanupDeadlineSeconds)) guard !Task.isCancelled else { return } self?.finishTerminationCleanup(for: sender) } @@ -469,7 +461,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.terminationDeadlineTask?.cancel() self.terminationCleanupTask = nil self.terminationDeadlineTask = nil - self.applicationTerminationReply(sender, true) + sender.reply(toApplicationShouldTerminate: true) } static func shouldPresentScheduledFirstRunOnboarding(onboardingSeen: Bool) -> Bool { diff --git a/apps/macos/Sources/OpenClaw/RemoteTunnelManager.swift b/apps/macos/Sources/OpenClaw/RemoteTunnelManager.swift index d23478bf2e75..6fd21b909fe6 100644 --- a/apps/macos/Sources/OpenClaw/RemoteTunnelManager.swift +++ b/apps/macos/Sources/OpenClaw/RemoteTunnelManager.swift @@ -33,11 +33,12 @@ actor RemoteTunnelManager { private var retirementInFlight: (token: UUID, task: Task)? private var tunnelGeneration: UInt64 = 0 private var lifecycleGeneration: UInt64 = 0 + private var isShutDown = false private var lastRestartAt: Date? private let restartBackoffSeconds: TimeInterval = 2.0 func controlTunnelRouteIfRunning() async -> Route? { - guard self.retirementInFlight == nil else { return nil } + guard !self.isShutDown, self.retirementInFlight == nil else { return nil } guard let configuration = try? RemotePortTunnel.configuration( remotePort: GatewayEnvironment.gatewayPort()) else { @@ -162,7 +163,8 @@ actor RemoteTunnelManager { } func ensureControlTunnelRoute() async throws -> Route { - try await self.ensureControlTunnelRoute( + guard !self.isShutDown else { throw CancellationError() } + return try await self.ensureControlTunnelRoute( lifecycleGeneration: self.lifecycleGeneration) } @@ -362,6 +364,12 @@ actor RemoteTunnelManager { return route } + func shutdown() async { + // Quit closes admission permanently; reconnect and mode changes still use stopAll. + self.isShutDown = true + await self.stopAll() + } + func stopAll() async { // Invalidate every captured route before terminating processes. Delayed // health checks and create completions cannot resurrect this epoch. diff --git a/apps/macos/Sources/OpenClaw/StatusMenuRenderer.swift b/apps/macos/Sources/OpenClaw/StatusMenuRenderer.swift index 2ce49161c7d5..1953a781e427 100644 --- a/apps/macos/Sources/OpenClaw/StatusMenuRenderer.swift +++ b/apps/macos/Sources/OpenClaw/StatusMenuRenderer.swift @@ -260,7 +260,7 @@ final class StatusMenuRenderer: NSObject { case .about: AppNavigationActions.openSettings(tab: .about) case .quit: - NSApplication.shared.terminate(nil) + AppDelegate.requestTermination() case .debug: break } diff --git a/apps/macos/Sources/OpenClaw/TerminationSignalWatcher.swift b/apps/macos/Sources/OpenClaw/TerminationSignalWatcher.swift index f5524d3904b5..e17b6e582df3 100644 --- a/apps/macos/Sources/OpenClaw/TerminationSignalWatcher.swift +++ b/apps/macos/Sources/OpenClaw/TerminationSignalWatcher.swift @@ -49,12 +49,11 @@ final class TerminationSignalWatcher { NodePairingApprovalPrompter.shared.stop() DevicePairingApprovalPrompter.shared.stop() Self.scheduleExitFailsafe() - NSApp.terminate(nil) + AppDelegate.requestTermination() } static func scheduleExitFailsafe() { - // AppKit waits in a nested event loop while async termination cleanup runs. - // A main-queue failsafe cannot fire from that loop, so enforce the deadline off-main. + // Keep the last-resort exit independent of a stuck main actor or AppKit loop. DispatchQueue.global(qos: .userInitiated).asyncAfter( deadline: .now() + AppTerminationTiming.signalExitFailsafeSeconds) { diff --git a/apps/macos/Tests/OpenClawIPCTests/RemoteTunnelManagerTests.swift b/apps/macos/Tests/OpenClawIPCTests/RemoteTunnelManagerTests.swift new file mode 100644 index 000000000000..ba6a69d9bcf5 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/RemoteTunnelManagerTests.swift @@ -0,0 +1,19 @@ +import Foundation +import Testing +@testable import OpenClaw + +struct RemoteTunnelManagerTests { + @Test func `shutdown rejects new tunnel work even after a reusable stop`() async { + let manager = RemoteTunnelManager() + await manager.shutdown() + await manager.stopAll() + + #expect(await manager.controlTunnelRouteIfRunning() == nil) + await #expect(throws: CancellationError.self) { + try await manager.ensureControlTunnelRoute() + } + await #expect(throws: CancellationError.self) { + try await manager.ensureControlTunnel() + } + } +}