fix(macos): drain SSH tunnels before app quit (#130421)

This commit is contained in:
Peter Steinberger
2026-08-26 14:48:35 -07:00
committed by GitHub
parent 54db7d4f99
commit f0ab89ed54
8 changed files with 63 additions and 50 deletions
@@ -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()
}
}
+9 -14
View File
@@ -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
@@ -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)
}
+18 -26
View File
@@ -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 {
@@ -33,11 +33,12 @@ actor RemoteTunnelManager {
private var retirementInFlight: (token: UUID, task: Task<Void, Never>)?
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.
@@ -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
}
@@ -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)
{
@@ -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()
}
}
}