From 92fb0caf35ab1dcf91ec150bc5820402ebdca3fb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 29 Mar 2026 00:35:20 +0000 Subject: [PATCH] fix: harden mac gateway attach smoke --- .../OpenClaw/GatewayProcessManager.swift | 14 +++ .../macos/Sources/OpenClaw/PortGuardian.swift | 20 +++++ .../GatewayProcessManagerTests.swift | 88 +++++++++++++++++++ .../NodeServiceManagerTests.swift | 20 +++-- 4 files changed, 133 insertions(+), 9 deletions(-) diff --git a/apps/macos/Sources/OpenClaw/GatewayProcessManager.swift b/apps/macos/Sources/OpenClaw/GatewayProcessManager.swift index e3d5263e9bc..c4595182d8d 100644 --- a/apps/macos/Sources/OpenClaw/GatewayProcessManager.swift +++ b/apps/macos/Sources/OpenClaw/GatewayProcessManager.swift @@ -44,6 +44,7 @@ final class GatewayProcessManager { private var logRefreshTask: Task? #if DEBUG private var testingConnection: GatewayConnection? + private var testingSkipControlChannelRefresh = false #endif private let logger = Logger(subsystem: "ai.openclaw", category: "gateway.process") @@ -364,6 +365,11 @@ final class GatewayProcessManager { } private func refreshControlChannelIfNeeded(reason: String) { + #if DEBUG + if self.testingSkipControlChannelRefresh { + return + } + #endif switch ControlChannel.shared.state { case .connected, .connecting: return @@ -421,6 +427,10 @@ extension GatewayProcessManager { self.testingConnection = connection } + func setTestingSkipControlChannelRefresh(_ skip: Bool) { + self.testingSkipControlChannelRefresh = skip + } + func setTestingDesiredActive(_ active: Bool) { self.desiredActive = active } @@ -428,5 +438,9 @@ extension GatewayProcessManager { func setTestingLastFailureReason(_ reason: String?) { self.lastFailureReason = reason } + + func _testAttachExistingGatewayIfAvailable() async -> Bool { + await self.attachExistingGatewayIfAvailable() + } } #endif diff --git a/apps/macos/Sources/OpenClaw/PortGuardian.swift b/apps/macos/Sources/OpenClaw/PortGuardian.swift index a7903044de8..1e16c30c998 100644 --- a/apps/macos/Sources/OpenClaw/PortGuardian.swift +++ b/apps/macos/Sources/OpenClaw/PortGuardian.swift @@ -23,6 +23,9 @@ actor PortGuardian { private var records: [Record] = [] private let logger = Logger(subsystem: "ai.openclaw", category: "portguard") + #if DEBUG + private var testingDescriptors: [Int: Descriptor] = [:] + #endif private nonisolated static let appSupportDir: URL = { let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first! return base.appendingPathComponent("OpenClaw", isDirectory: true) @@ -130,6 +133,11 @@ actor PortGuardian { } func describe(port: Int) async -> Descriptor? { + #if DEBUG + if let descriptor = self.testingDescriptors[port] { + return descriptor + } + #endif guard let listener = await self.listeners(on: port).first else { return nil } let path = Self.executablePath(for: listener.pid) return Descriptor(pid: listener.pid, command: listener.command, executablePath: path) @@ -406,6 +414,18 @@ actor PortGuardian { } } +#if DEBUG +extension PortGuardian { + func setTestingDescriptor(_ descriptor: Descriptor?, forPort port: Int) { + if let descriptor { + self.testingDescriptors[port] = descriptor + } else { + self.testingDescriptors.removeValue(forKey: port) + } + } +} +#endif + #if DEBUG extension PortGuardian { static func _testParseListeners(_ text: String) -> [( diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift index 78c0116f73c..da6c60372c9 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift @@ -35,4 +35,92 @@ struct GatewayProcessManagerTests { #expect(ready) #expect(manager.lastFailureReason == nil) } + + @Test func `attaches to existing gateway without spawning launchd`() async throws { + let healthData = Data( + """ + { + "ok": true, + "ts": 1, + "durationMs": 0, + "channels": { + "telegram": { + "configured": true, + "linked": true, + "authAgeMs": 60000 + } + }, + "channelOrder": ["telegram"], + "channelLabels": { + "telegram": "Telegram" + }, + "heartbeatSeconds": 30, + "sessions": { + "path": "/tmp/sessions", + "count": 1, + "recent": [] + } + } + """.utf8) + let session = GatewayTestWebSocketSession( + taskFactory: { + GatewayTestWebSocketTask( + sendHook: { task, message, sendIndex in + guard sendIndex > 0 else { return } + guard let id = GatewayWebSocketTestSupport.requestID(from: message) else { return } + let json = """ + { + "type": "res", + "id": "\(id)", + "ok": true, + "payload": \(String(decoding: healthData, as: UTF8.self)) + } + """ + task.emitReceiveSuccess(.data(Data(json.utf8))) + }) + }) + let url = try #require(URL(string: "ws://example.invalid")) + let connection = GatewayConnection( + configProvider: { (url: url, token: nil, password: nil) }, + sessionBox: WebSocketSessionBox(session: session)) + let port = GatewayEnvironment.gatewayPort() + let descriptor = PortGuardian.Descriptor( + pid: 4242, + command: "openclaw-gateway", + executablePath: "/tmp/openclaw-gateway") + + let manager = GatewayProcessManager.shared + await PortGuardian.shared.setTestingDescriptor(descriptor, forPort: port) + manager.setTestingConnection(connection) + manager.setTestingSkipControlChannelRefresh(true) + manager.setTestingLastFailureReason("stale") + + func cleanup() async { + await PortGuardian.shared.setTestingDescriptor(nil, forPort: port) + manager.setTestingConnection(nil) + manager.setTestingSkipControlChannelRefresh(false) + manager.setTestingDesiredActive(false) + manager.setTestingLastFailureReason(nil) + } + + do { + let attached = await manager._testAttachExistingGatewayIfAvailable() + #expect(attached) + #expect(manager.lastFailureReason == nil) + guard case let .attachedExisting(statusDetails) = manager.status else { + Issue.record("expected attachedExisting status") + await cleanup() + return + } + let details = try #require(statusDetails) + #expect(details.contains("port \(port)")) + #expect(details.contains("Telegram linked")) + #expect(details.contains("auth 1m")) + #expect(details.contains("pid 4242 openclaw-gateway @ /tmp/openclaw-gateway")) + await cleanup() + } catch { + await cleanup() + throw error + } + } } diff --git a/apps/macos/Tests/OpenClawIPCTests/NodeServiceManagerTests.swift b/apps/macos/Tests/OpenClawIPCTests/NodeServiceManagerTests.swift index df49a82e223..557ff1b686c 100644 --- a/apps/macos/Tests/OpenClawIPCTests/NodeServiceManagerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/NodeServiceManagerTests.swift @@ -3,17 +3,19 @@ import Testing @testable import OpenClaw @Suite(.serialized) struct NodeServiceManagerTests { - @Test func `builds node service commands with current CLI shape`() throws { - let tmp = try makeTempDirForTests() - CommandResolver.setProjectRoot(tmp.path) + @Test func `builds node service commands with current CLI shape`() async throws { + try await TestIsolation.withUserDefaultsValues(["openclaw.gatewayProjectRootPath": nil]) { + let tmp = try makeTempDirForTests() + CommandResolver.setProjectRoot(tmp.path) - let openclawPath = tmp.appendingPathComponent("node_modules/.bin/openclaw") - try makeExecutableForTests(at: openclawPath) + let openclawPath = tmp.appendingPathComponent("node_modules/.bin/openclaw") + try makeExecutableForTests(at: openclawPath) - let start = NodeServiceManager._testServiceCommand(["start"]) - #expect(start == [openclawPath.path, "node", "start", "--json"]) + let start = NodeServiceManager._testServiceCommand(["start"]) + #expect(start == [openclawPath.path, "node", "start", "--json"]) - let stop = NodeServiceManager._testServiceCommand(["stop"]) - #expect(stop == [openclawPath.path, "node", "stop", "--json"]) + let stop = NodeServiceManager._testServiceCommand(["stop"]) + #expect(stop == [openclawPath.path, "node", "stop", "--json"]) + } } }