mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
fix(macos): drain SSH tunnels before app quit (#130421)
This commit is contained in:
committed by
GitHub
parent
54db7d4f99
commit
f0ab89ed54
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user