fix(macos): duplicate launches open SSH tunnels before handoff (#103892)

* fix(macos): gate tunnels behind singleton launch

* chore(macos): leave changelog to release workflow

* chore(macos): sync native i18n inventory
This commit is contained in:
Peter Steinberger
2026-07-10 19:54:04 +01:00
committed by GitHub
parent 7af7e41eed
commit 5a48e73973
4 changed files with 54 additions and 9 deletions
+5 -5
View File
@@ -18147,7 +18147,7 @@
},
{
"kind": "ui-call",
"line": 103,
"line": 102,
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
"source": "Settings...",
"surface": "apple",
@@ -18155,7 +18155,7 @@
},
{
"kind": "conditional-branch",
"line": 122,
"line": 121,
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
"source": "OpenClaw",
"surface": "apple",
@@ -18163,7 +18163,7 @@
},
{
"kind": "conditional-branch",
"line": 122,
"line": 121,
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
"source": "OpenClaw - Voice Wake live meter active",
"surface": "apple",
@@ -18171,7 +18171,7 @@
},
{
"kind": "conditional-branch",
"line": 317,
"line": 316,
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
"source": "Close Canvas",
"surface": "apple",
@@ -18179,7 +18179,7 @@
},
{
"kind": "conditional-branch",
"line": 317,
"line": 316,
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
"source": "Open Canvas",
"surface": "apple",
@@ -38,6 +38,7 @@ actor GatewayEndpointStore {
let localPort: @Sendable () -> Int
let localHost: @Sendable () async -> String
let remotePortIfRunning: @Sendable () async -> UInt16?
let canStartRemoteTunnel: @Sendable () -> Bool
let ensureRemoteTunnel: @Sendable () async throws -> UInt16
static let live = Deps(
@@ -75,9 +76,16 @@ actor GatewayEndpointStore {
tailscaleIP: tailscaleIP)
},
remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() },
canStartRemoteTunnel: { GatewayEndpointStore.primaryAppLaunchAdmitted.withValue { $0 } },
ensureRemoteTunnel: { try await RemoteTunnelManager.shared.ensureControlTunnel() })
}
private static let primaryAppLaunchAdmitted = LockIsolated(false)
static func admitPrimaryAppLaunch() {
self.primaryAppLaunchAdmitted.withValue { $0 = true }
}
private static func resolveGatewayPassword(
isRemote: Bool,
root: [String: Any],
@@ -461,10 +469,15 @@ actor GatewayEndpointStore {
self.remoteEnsure = nil
}
private func kickRemoteEnsureIfNeeded(detail: String) {
@discardableResult
private func kickRemoteEnsureIfNeeded(detail: String) -> Bool {
guard self.deps.canStartRemoteTunnel() else {
self.setState(.connecting(mode: .remote, detail: detail))
return false
}
if self.remoteEnsure != nil {
self.setState(.connecting(mode: .remote, detail: detail))
return
return true
}
let deps = self.deps
@@ -472,6 +485,7 @@ actor GatewayEndpointStore {
let task = Task.detached(priority: .utility) { try await deps.ensureRemoteTunnel() }
self.remoteEnsure = (token: token, task: task)
self.setState(.connecting(mode: .remote, detail: detail))
return true
}
private func ensureRemoteConfig(detail: String) async throws -> GatewayConnection.Config {
@@ -485,7 +499,9 @@ actor GatewayEndpointStore {
return (url, token, password)
}
self.kickRemoteEnsureIfNeeded(detail: detail)
guard self.kickRemoteEnsureIfNeeded(detail: detail) else {
throw CancellationError()
}
guard let ensure = self.remoteEnsure else {
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"])
}
+4 -1
View File
@@ -33,7 +33,6 @@ struct OpenClawApp: App {
init() {
OpenClawLogging.bootstrapIfNeeded()
GatewayConnectivityCoordinator.shared.start()
Self.applyAttachOnlyOverrideIfNeeded()
_state = State(initialValue: AppStateStore.shared)
@@ -379,6 +378,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
NSApp.terminate(nil)
return
}
// Remote startup can spawn an SSH child. Admit tunnel work only after the
// singleton check so a short-lived handoff process cannot orphan that child.
GatewayEndpointStore.admitPrimaryAppLaunch()
GatewayConnectivityCoordinator.shared.start()
self.state = AppStateStore.shared
if let state {
MacNodeModeCoordinator.prepareNodeIdentityProfile(
@@ -1,8 +1,34 @@
import ConcurrencyExtras
import Foundation
import Testing
@testable import OpenClaw
struct GatewayEndpointStoreTests {
@Test func `remote tunnel waits for primary app launch admission`() async throws {
let admitted = LockIsolated(false)
let tunnelStarts = LockIsolated(0)
let deps = GatewayEndpointStore.Deps(
mode: { .remote },
token: { nil },
password: { nil },
localPort: { 18789 },
localHost: { "127.0.0.1" },
remotePortIfRunning: { nil },
canStartRemoteTunnel: { admitted.withValue { $0 } },
ensureRemoteTunnel: {
tunnelStarts.withValue { $0 += 1 }
return 18789
})
let store = GatewayEndpointStore(deps: deps)
await store.setMode(.remote)
#expect(tunnelStarts.withValue { $0 } == 0)
admitted.withValue { $0 = true }
#expect(try await store.ensureRemoteControlTunnel() == 18789)
#expect(tunnelStarts.withValue { $0 } == 1)
}
private func makeLaunchAgentSnapshot(
env: [String: String],
token: String?,