fix: harden mac gateway attach smoke

This commit is contained in:
Peter Steinberger
2026-03-29 00:35:20 +00:00
parent f9281d1b9d
commit 92fb0caf35
4 changed files with 133 additions and 9 deletions

View File

@@ -44,6 +44,7 @@ final class GatewayProcessManager {
private var logRefreshTask: Task<Void, Never>?
#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

View File

@@ -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) -> [(

View File

@@ -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
}
}
}

View File

@@ -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"])
}
}
}