mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
0dbdf994b3
* feat(macos): isolate named app profiles * refactor(macos): isolate profile launch ownership * fix(macos): avoid overlapping approvals socket access * fix(macos): declare profile defaults concurrency ownership * fix(macos): return profiled node launch arguments * chore(i18n): refresh macOS profile source inventory * fix(macos): gate profile startup before services * test(macos): evaluate profile state before assertions * fix(daemon): skip absent launchd deactivation * fix(macos): fail closed on profile port conflicts * chore(i18n): refresh profile conflict inventory * fix(macos): ignore non-gateway launch agent claims * test(macos): stabilize profile lifecycle timing * fix(macos): remove stale dashboard URL * chore(macos): refresh native source baseline
1682 lines
75 KiB
Swift
1682 lines
75 KiB
Swift
import Darwin
|
|
import Foundation
|
|
import Testing
|
|
@testable import OpenClaw
|
|
@testable import OpenClawKit
|
|
|
|
@Suite(.serialized)
|
|
@MainActor
|
|
struct GatewayProcessManagerTests {
|
|
@Test func `colliding profile ports cannot attach another profile gateway`() {
|
|
let first = AppProfile(environment: ["OPENCLAW_PROFILE": "p1402"])
|
|
let second = AppProfile(environment: ["OPENCLAW_PROFILE": "p2380"])
|
|
#expect(first.defaultGatewayPort == 55636)
|
|
#expect(second.defaultGatewayPort == 55636)
|
|
#expect(GatewayProcessManager.profileAllowsExistingGatewayAttachment(
|
|
profile: first,
|
|
listenerPID: 1402,
|
|
managedServicePID: 1402))
|
|
#expect(!GatewayProcessManager.profileAllowsExistingGatewayAttachment(
|
|
profile: second,
|
|
listenerPID: 1402,
|
|
managedServicePID: 2380))
|
|
#expect(!GatewayProcessManager.profileAllowsExistingGatewayAttachment(
|
|
profile: second,
|
|
listenerPID: 1402,
|
|
managedServicePID: nil))
|
|
#expect(GatewayProcessManager.profileAllowsExistingGatewayAttachment(
|
|
profile: AppProfile(environment: [:]),
|
|
listenerPID: 1402,
|
|
managedServicePID: nil))
|
|
}
|
|
|
|
private func availableGatewayPort() throws -> Int {
|
|
let fd = socket(AF_INET, SOCK_STREAM, 0)
|
|
guard fd >= 0 else {
|
|
throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno))
|
|
}
|
|
defer { _ = Darwin.close(fd) }
|
|
|
|
var address = sockaddr_in()
|
|
address.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
|
|
address.sin_family = sa_family_t(AF_INET)
|
|
address.sin_port = 0
|
|
address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
|
|
let bound = withUnsafePointer(to: &address) { pointer in
|
|
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { socketAddress in
|
|
Darwin.bind(fd, socketAddress, socklen_t(MemoryLayout<sockaddr_in>.size))
|
|
}
|
|
}
|
|
guard bound == 0 else {
|
|
throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno))
|
|
}
|
|
|
|
var assigned = sockaddr_in()
|
|
var assignedLength = socklen_t(MemoryLayout<sockaddr_in>.size)
|
|
let resolved = withUnsafeMutablePointer(to: &assigned) { pointer in
|
|
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { socketAddress in
|
|
getsockname(fd, socketAddress, &assignedLength)
|
|
}
|
|
}
|
|
guard resolved == 0 else {
|
|
throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno))
|
|
}
|
|
return Int(UInt16(bigEndian: assigned.sin_port))
|
|
}
|
|
|
|
private func withGatewayConfig<T>(
|
|
mode: String,
|
|
port: Int? = nil,
|
|
_ body: () async throws -> T) async throws -> T
|
|
{
|
|
let configPath = TestIsolation.tempConfigPath()
|
|
let portFragment = port.map { ",\"port\":\($0)" } ?? ""
|
|
let config = #"{"gateway":{"mode":"\#(mode)""# + portFragment + "}}"
|
|
try Data(config.utf8)
|
|
.write(to: URL(fileURLWithPath: configPath))
|
|
defer { try? FileManager.default.removeItem(atPath: configPath) }
|
|
return try await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath], body)
|
|
}
|
|
|
|
private func withLaunchAgentEnvironment<T>(
|
|
mode: String = "local",
|
|
port: Int? = nil,
|
|
statusPayload: String? = nil,
|
|
statusPayloads: [String]? = nil,
|
|
commandDelayNanoseconds: UInt64 = 0,
|
|
_ body: () async throws -> T) async throws -> T
|
|
{
|
|
let marker = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("openclaw-launchagent-marker-\(UUID().uuidString)")
|
|
return try await self.withGatewayConfig(mode: mode, port: port) {
|
|
GatewayLaunchAgentManager.setTestingDisableLaunchAgentMarkerURL(marker)
|
|
GatewayLaunchAgentManager.setTestingInterceptDaemonCommands(true)
|
|
if let statusPayloads {
|
|
GatewayLaunchAgentManager.setTestingDaemonStatusPayloads(statusPayloads)
|
|
} else {
|
|
GatewayLaunchAgentManager.setTestingDaemonStatusPayload(statusPayload)
|
|
}
|
|
GatewayLaunchAgentManager.setTestingDaemonCommandDelayNanoseconds(commandDelayNanoseconds)
|
|
GatewayLaunchAgentManager.clearTestingDaemonCommandCalls()
|
|
defer {
|
|
GatewayLaunchAgentManager.setTestingDisableLaunchAgentMarkerURL(nil)
|
|
GatewayLaunchAgentManager.setTestingInterceptDaemonCommands(false)
|
|
GatewayLaunchAgentManager.setTestingDaemonStatusPayload(nil)
|
|
GatewayLaunchAgentManager.setTestingDaemonCommandDelayNanoseconds(0)
|
|
GatewayLaunchAgentManager.clearTestingDaemonCommandCalls()
|
|
GatewayProcessManager.shared.setTestingDesiredActive(false)
|
|
GatewayProcessManager.shared._testClearLaunchAgentReadinessFailure()
|
|
GatewayProcessManager.shared._testClearLaunchAgentInstallEvidence()
|
|
}
|
|
return try await body()
|
|
}
|
|
}
|
|
|
|
private func makeGatewayReadinessFixture(
|
|
url: URL,
|
|
taskFactory: @escaping GatewayTestWebSocketSession.TaskFactory)
|
|
-> (session: GatewayTestWebSocketSession, connection: GatewayConnection, manager: GatewayProcessManager)
|
|
{
|
|
let session = GatewayTestWebSocketSession(taskFactory: taskFactory)
|
|
let connection = GatewayConnection(
|
|
configProvider: { (url: url, token: nil, password: nil) },
|
|
sessionBox: WebSocketSessionBox(session: session))
|
|
let manager = GatewayProcessManager.shared
|
|
manager._testResetGatewayStartTask()
|
|
manager.setTestingConnection(connection)
|
|
return (session, connection, manager)
|
|
}
|
|
|
|
private func gatewayDescriptor(
|
|
pid: Int32,
|
|
command: String = "openclaw-gateway",
|
|
executablePath: String = "/tmp/openclaw-gateway") -> PortGuardian.Descriptor
|
|
{
|
|
PortGuardian.Descriptor(pid: pid, command: command, executablePath: executablePath)
|
|
}
|
|
|
|
private nonisolated func gatewayTask(
|
|
healthSucceedsAfter unavailableResponses: Int?,
|
|
stallsFirstHealthResponse: Bool = false,
|
|
healthResponseGates: [AsyncTestGate] = []) -> GatewayTestWebSocketTask
|
|
{
|
|
GatewayTestWebSocketTask(
|
|
sendHook: { task, message, sendIndex in
|
|
guard sendIndex > 0 else { return }
|
|
guard let id = GatewayWebSocketTestSupport.requestID(from: message) else { return }
|
|
if healthResponseGates.indices.contains(sendIndex - 1) {
|
|
await healthResponseGates[sendIndex - 1].wait()
|
|
}
|
|
if stallsFirstHealthResponse, sendIndex == 1 { return }
|
|
if unavailableResponses.map({ sendIndex <= $0 }) ?? true {
|
|
let response = Data(
|
|
"""
|
|
{"type":"res","id":"\(id)","ok":false,
|
|
"error":{"code":"UNAVAILABLE","message":"gateway awaiting authorization"}}
|
|
""".utf8)
|
|
task.emitReceiveSuccess(.data(response))
|
|
return
|
|
}
|
|
task.emitReceiveSuccess(.data(GatewayWebSocketTestSupport.okResponseData(id: id)))
|
|
})
|
|
}
|
|
|
|
private func loadedGatewayStatus(
|
|
port: Int,
|
|
pid: Int32 = 4242,
|
|
configAudit: String = #"{"ok":true,"issues":[]}"#) -> String
|
|
{
|
|
"""
|
|
{"ok":true,"service":{
|
|
"loaded":true,
|
|
"runtime":{"status":"running","pid":\(pid)},
|
|
"command":{"programArguments":["openclaw","gateway","--port","\(port)"]},
|
|
"configAudit":\(configAudit)
|
|
}}
|
|
"""
|
|
}
|
|
|
|
private func waitForCondition(
|
|
attempts: Int = 100,
|
|
_ condition: () -> Bool) async
|
|
{
|
|
for _ in 0..<attempts {
|
|
if condition() { break }
|
|
try? await Task.sleep(nanoseconds: 1_000_000)
|
|
}
|
|
}
|
|
|
|
@Test func `coalesces concurrent launch agent enable requests`() async throws {
|
|
let port = 19081
|
|
try await self.withLaunchAgentEnvironment(
|
|
statusPayload: #"{"ok":true,"service":{"loaded":false}}"#)
|
|
{
|
|
let manager = GatewayProcessManager.shared
|
|
async let first: String? = manager._testEnableLaunchAgentIfNeeded(
|
|
bundlePath: "/Applications/OpenClaw.app",
|
|
port: port)
|
|
async let second: String? = manager._testEnableLaunchAgentIfNeeded(
|
|
bundlePath: "/Applications/OpenClaw.app",
|
|
port: port)
|
|
_ = await (first, second)
|
|
|
|
let calls = GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
#expect(calls.filter { $0.first == "status" }.count == 1)
|
|
#expect(calls.filter { $0.first == "install" }.count == 1)
|
|
}
|
|
}
|
|
|
|
@Test func `queues a changed launch agent request behind an in-flight request`() async throws {
|
|
let firstPort = 19091
|
|
let secondPort = 19092
|
|
try await self.withLaunchAgentEnvironment(
|
|
statusPayload: #"{"ok":true,"service":{"loaded":false}}"#,
|
|
commandDelayNanoseconds: 100_000_000)
|
|
{
|
|
let manager = GatewayProcessManager.shared
|
|
let first = Task { @MainActor in
|
|
await manager._testEnableLaunchAgentIfNeeded(
|
|
bundlePath: "/Applications/OpenClaw.app",
|
|
port: firstPort)
|
|
}
|
|
await self.waitForCondition {
|
|
!GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot().isEmpty
|
|
}
|
|
#expect(!GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot().isEmpty)
|
|
|
|
let second = Task { @MainActor in
|
|
await manager._testEnableLaunchAgentIfNeeded(
|
|
bundlePath: "/Applications/OpenClaw.app",
|
|
port: secondPort)
|
|
}
|
|
#expect(await first.value == nil)
|
|
#expect(await second.value == nil)
|
|
|
|
let calls = GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
let installPorts = calls.compactMap { arguments -> String? in
|
|
guard arguments.first == "install",
|
|
let portIndex = arguments.firstIndex(of: "--port"),
|
|
arguments.indices.contains(portIndex + 1)
|
|
else {
|
|
return nil
|
|
}
|
|
return arguments[portIndex + 1]
|
|
}
|
|
#expect(installPorts == [String(firstPort), String(secondPort)])
|
|
|
|
GatewayLaunchAgentManager.clearTestingDaemonCommandCalls()
|
|
let newestPort = 19093
|
|
let stalePort = 19094
|
|
let current = Task { @MainActor in
|
|
await manager._testEnableLaunchAgentIfNeeded(
|
|
bundlePath: "/Applications/OpenClaw.app",
|
|
port: newestPort)
|
|
}
|
|
await self.waitForCondition {
|
|
!GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot().isEmpty
|
|
}
|
|
let stale = Task { @MainActor in
|
|
await manager._testEnableLaunchAgentIfNeeded(
|
|
bundlePath: "/Applications/OpenClaw.app",
|
|
port: stalePort)
|
|
}
|
|
await self.waitForCondition {
|
|
manager._testPendingLaunchAgentPort() == stalePort
|
|
}
|
|
#expect(manager._testPendingLaunchAgentPort() == stalePort)
|
|
let newest = Task { @MainActor in
|
|
await manager._testEnableLaunchAgentIfNeeded(
|
|
bundlePath: "/Applications/OpenClaw.app",
|
|
port: newestPort)
|
|
}
|
|
#expect(await current.value == nil)
|
|
#expect(await stale.value == nil)
|
|
#expect(await newest.value == nil)
|
|
|
|
let finalInstallPorts = GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
.compactMap { arguments -> String? in
|
|
guard arguments.first == "install",
|
|
let portIndex = arguments.firstIndex(of: "--port"),
|
|
arguments.indices.contains(portIndex + 1)
|
|
else {
|
|
return nil
|
|
}
|
|
return arguments[portIndex + 1]
|
|
}
|
|
#expect(finalInstallPorts == [String(newestPort)])
|
|
}
|
|
}
|
|
|
|
@Test func `coalesced drain returns each request installation result`() async throws {
|
|
let firstPort = 19107
|
|
let secondPort = 19108
|
|
try await self.withLaunchAgentEnvironment(
|
|
statusPayloads: [
|
|
#"{"ok":true,"service":{"loaded":false}}"#,
|
|
self.loadedGatewayStatus(port: secondPort),
|
|
],
|
|
commandDelayNanoseconds: 100_000_000)
|
|
{
|
|
let manager = GatewayProcessManager.shared
|
|
let first = Task { @MainActor in
|
|
await manager._testEnableLaunchAgentIfNeededInstalled(
|
|
bundlePath: "/Applications/OpenClaw.app",
|
|
port: firstPort)
|
|
}
|
|
await self.waitForCondition(attempts: 1000) {
|
|
GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
.contains(where: { $0.first == "install" })
|
|
}
|
|
#expect(GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
.contains(where: { $0.first == "install" }))
|
|
|
|
let second = Task { @MainActor in
|
|
await manager._testEnableLaunchAgentIfNeededInstalled(
|
|
bundlePath: "/Applications/OpenClaw.app",
|
|
port: secondPort)
|
|
}
|
|
|
|
#expect(await first.value)
|
|
#expect(await second.value == false)
|
|
let calls = GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
#expect(calls.filter { $0.first == "install" }.count == 1)
|
|
}
|
|
}
|
|
|
|
@Test func `stop discards queued enables and disables after the active request`() async throws {
|
|
let firstPort = 19095
|
|
let secondPort = 19096
|
|
try await self.withLaunchAgentEnvironment(
|
|
statusPayload: #"{"ok":true,"service":{"loaded":false}}"#,
|
|
commandDelayNanoseconds: 100_000_000)
|
|
{
|
|
let manager = GatewayProcessManager.shared
|
|
manager.setTestingDesiredActive(true)
|
|
let first = Task { @MainActor in
|
|
await manager._testEnableLaunchAgentIfNeeded(
|
|
bundlePath: "/Applications/OpenClaw.app",
|
|
port: firstPort)
|
|
}
|
|
await self.waitForCondition {
|
|
!GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot().isEmpty
|
|
}
|
|
let second = Task { @MainActor in
|
|
await manager._testEnableLaunchAgentIfNeeded(
|
|
bundlePath: "/Applications/OpenClaw.app",
|
|
port: secondPort)
|
|
}
|
|
await self.waitForCondition {
|
|
manager._testPendingLaunchAgentPort() == secondPort
|
|
}
|
|
#expect(manager._testPendingLaunchAgentPort() == secondPort)
|
|
|
|
manager.stop()
|
|
_ = await (first.value, second.value)
|
|
try? await Task.sleep(nanoseconds: 150_000_000)
|
|
|
|
let calls = GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
let installPorts = calls.compactMap { arguments -> String? in
|
|
guard arguments.first == "install",
|
|
let portIndex = arguments.firstIndex(of: "--port"),
|
|
arguments.indices.contains(portIndex + 1)
|
|
else {
|
|
return nil
|
|
}
|
|
return arguments[portIndex + 1]
|
|
}
|
|
#expect(installPorts == [String(firstPort)])
|
|
#expect(calls.filter { $0.first == "uninstall" }.count == 1)
|
|
#expect(manager._testPendingLaunchAgentPort() == nil)
|
|
#expect(manager.status == .stopped)
|
|
}
|
|
}
|
|
|
|
@Test func `restart waits for an in-progress disable`() async throws {
|
|
let port = 19098
|
|
try await self.withLaunchAgentEnvironment(
|
|
statusPayload: #"{"ok":true,"service":{"loaded":false}}"#,
|
|
commandDelayNanoseconds: 100_000_000)
|
|
{
|
|
let manager = GatewayProcessManager.shared
|
|
manager.setTestingDesiredActive(true)
|
|
manager.stop()
|
|
await self.waitForCondition {
|
|
GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
.contains(where: { $0.first == "uninstall" })
|
|
}
|
|
#expect(GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
.contains(where: { $0.first == "uninstall" }))
|
|
|
|
manager._testBeginGatewayStartGeneration()
|
|
_ = await manager._testEnableLaunchAgentIfNeeded(
|
|
bundlePath: "/Applications/OpenClaw.app",
|
|
port: port)
|
|
|
|
let calls = GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
#expect(calls.map(\.first) == ["uninstall", "status", "install"])
|
|
}
|
|
}
|
|
|
|
@Test func `restart waits for disable before attaching`() async throws {
|
|
let port = 19099
|
|
let url = try #require(URL(string: "ws://example.invalid"))
|
|
let (_, connection, manager) = self.makeGatewayReadinessFixture(url: url) {
|
|
GatewayTestWebSocketTask(
|
|
sendHook: { task, message, sendIndex in
|
|
guard sendIndex > 0 else { return }
|
|
guard let id = GatewayWebSocketTestSupport.requestID(from: message) else { return }
|
|
task.emitReceiveSuccess(.data(GatewayWebSocketTestSupport.okResponseData(id: id)))
|
|
})
|
|
}
|
|
defer { manager.setTestingConnection(nil) }
|
|
let descriptor = self.gatewayDescriptor(pid: 4242)
|
|
|
|
try await self.withLaunchAgentEnvironment(commandDelayNanoseconds: 100_000_000) {
|
|
manager.setTestingSkipControlChannelRefresh(true)
|
|
manager.setTestingDesiredActive(true)
|
|
await PortGuardian.shared.setTestingDescriptor(descriptor, forPort: port)
|
|
defer {
|
|
manager.setTestingSkipControlChannelRefresh(false)
|
|
manager.setTestingDesiredActive(false)
|
|
}
|
|
|
|
manager.stop()
|
|
await self.waitForCondition {
|
|
GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
.contains(where: { $0.first == "uninstall" })
|
|
}
|
|
manager._testBeginGatewayStartGeneration()
|
|
|
|
let startedAt = Date()
|
|
let attached = await manager._testAttachExistingGatewayAfterPendingDisable(port: port)
|
|
let elapsed = Date().timeIntervalSince(startedAt)
|
|
|
|
#expect(attached)
|
|
#expect(elapsed >= 0.05)
|
|
#expect(GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
.filter { $0.first == "uninstall" }.count == 1)
|
|
guard case .attachedExisting = manager.status else {
|
|
Issue.record("expected attachedExisting status")
|
|
await PortGuardian.shared.setTestingDescriptor(nil, forPort: port)
|
|
await connection.shutdown()
|
|
return
|
|
}
|
|
await PortGuardian.shared.setTestingDescriptor(nil, forPort: port)
|
|
await connection.shutdown()
|
|
}
|
|
}
|
|
|
|
@Test func `remote mode still removes the local launch agent`() async throws {
|
|
try await self.withLaunchAgentEnvironment(mode: "remote") {
|
|
let manager = GatewayProcessManager.shared
|
|
manager.setTestingDesiredActive(true)
|
|
manager.stop()
|
|
await self.waitForCondition {
|
|
GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
.contains(where: { $0.first == "uninstall" })
|
|
}
|
|
|
|
let calls = GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
#expect(calls.filter { $0.first == "uninstall" }.count == 1)
|
|
#expect(manager.status == .stopped)
|
|
}
|
|
}
|
|
|
|
@Test func `inactive lifecycle skips persistence ensure`() async throws {
|
|
try await self.withLaunchAgentEnvironment {
|
|
let manager = GatewayProcessManager.shared
|
|
manager.setTestingDesiredActive(false)
|
|
_ = await manager.ensureLaunchAgentEnabledIfNeeded()
|
|
|
|
#expect(GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot().isEmpty)
|
|
}
|
|
}
|
|
|
|
@Test func `newer inactive lifecycle retains the pending disable`() async throws {
|
|
try await self.withLaunchAgentEnvironment(commandDelayNanoseconds: 100_000_000) {
|
|
let manager = GatewayProcessManager.shared
|
|
manager.setTestingDesiredActive(true)
|
|
manager.stop()
|
|
manager.stop()
|
|
await self.waitForCondition(attempts: 200) {
|
|
GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
.contains(where: { $0.first == "uninstall" })
|
|
}
|
|
try? await Task.sleep(nanoseconds: 150_000_000)
|
|
|
|
let calls = GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
#expect(calls.filter { $0.first == "uninstall" }.count == 1)
|
|
#expect(manager.status == .stopped)
|
|
}
|
|
}
|
|
|
|
@Test func `keeps a reusable launch agent running`() async throws {
|
|
let port = 19082
|
|
try await self.withLaunchAgentEnvironment {
|
|
let reusableAudits = [
|
|
#"{"ok":true,"issues":[]}"#,
|
|
#"{"ok":false,"issues":[{"code":"gateway-path-nonminimal","level":"recommended"}]}"#,
|
|
]
|
|
for configAudit in reusableAudits {
|
|
GatewayLaunchAgentManager.setTestingDaemonStatusPayload(
|
|
self.loadedGatewayStatus(port: port, configAudit: configAudit))
|
|
GatewayLaunchAgentManager.clearTestingDaemonCommandCalls()
|
|
|
|
_ = await GatewayProcessManager.shared._testEnableLaunchAgentIfNeeded(
|
|
bundlePath: "/Applications/OpenClaw.app",
|
|
port: port)
|
|
|
|
let calls = GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
#expect(calls.filter { $0.first == "status" }.count == 1)
|
|
#expect(calls.allSatisfy { $0.first != "install" })
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test func `repairs only a stable launch agent PID after readiness fails`() async throws {
|
|
let port = 19085
|
|
try await self.withLaunchAgentEnvironment(statusPayload: self.loadedGatewayStatus(port: port)) {
|
|
let manager = GatewayProcessManager.shared
|
|
_ = await manager._testEnableLaunchAgentIfNeeded(
|
|
bundlePath: "/Applications/OpenClaw.app",
|
|
port: port)
|
|
var calls = GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
#expect(calls.filter { $0.first == "install" }.isEmpty)
|
|
|
|
GatewayLaunchAgentManager.clearTestingDaemonCommandCalls()
|
|
await manager._testRecordLaunchAgentReadinessFailure(port: port, startingPID: 4242)
|
|
GatewayLaunchAgentManager.clearTestingDaemonCommandCalls()
|
|
|
|
_ = await manager._testEnableLaunchAgentIfNeeded(
|
|
bundlePath: "/Applications/OpenClaw.app",
|
|
port: port)
|
|
|
|
calls = GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
#expect(calls.filter { $0.first == "status" }.count == 1)
|
|
#expect(calls.filter { $0.first == "install" }.count == 1)
|
|
#expect(!manager._testHasLaunchAgentFreshInstallEvidence())
|
|
}
|
|
}
|
|
|
|
@Test func `gives a replacement launch agent PID a full readiness cycle`() async throws {
|
|
let port = 19086
|
|
try await self.withLaunchAgentEnvironment(
|
|
statusPayload: self.loadedGatewayStatus(port: port, pid: 4243))
|
|
{
|
|
let manager = GatewayProcessManager.shared
|
|
await manager._testRecordLaunchAgentReadinessFailure(port: port, startingPID: 4242)
|
|
GatewayLaunchAgentManager.clearTestingDaemonCommandCalls()
|
|
|
|
_ = await manager._testEnableLaunchAgentIfNeeded(
|
|
bundlePath: "/Applications/OpenClaw.app",
|
|
port: port)
|
|
|
|
let calls = GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
#expect(calls.filter { $0.first == "status" }.count == 1)
|
|
#expect(calls.filter { $0.first == "install" }.isEmpty)
|
|
}
|
|
}
|
|
|
|
@Test func `stop wins while a readiness failure audit is pending`() async throws {
|
|
let port = 19089
|
|
try await self.withLaunchAgentEnvironment(
|
|
statusPayload: self.loadedGatewayStatus(port: port),
|
|
commandDelayNanoseconds: 100_000_000)
|
|
{
|
|
let manager = GatewayProcessManager.shared
|
|
manager.setTestingDesiredActive(true)
|
|
let finish = Task { @MainActor in
|
|
await manager._testFinishLaunchAgentReadinessFailure(
|
|
port: port,
|
|
startingPID: 4242)
|
|
}
|
|
await self.waitForCondition {
|
|
GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
.contains(where: { $0.first == "status" })
|
|
}
|
|
#expect(GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
.contains(where: { $0.first == "status" }))
|
|
|
|
manager.stop()
|
|
await finish.value
|
|
try? await Task.sleep(nanoseconds: 150_000_000)
|
|
|
|
#expect(manager.status == .stopped)
|
|
#expect(!manager._testHasLaunchAgentReadinessFailure())
|
|
}
|
|
}
|
|
|
|
@Test func `stale readiness audit cannot clear a restarted generation`() async throws {
|
|
let port = 19090
|
|
try await self.withLaunchAgentEnvironment(
|
|
statusPayload: self.loadedGatewayStatus(port: port),
|
|
commandDelayNanoseconds: 200_000_000)
|
|
{
|
|
let manager = GatewayProcessManager.shared
|
|
manager.setTestingDesiredActive(true)
|
|
let staleFinish = Task { @MainActor in
|
|
await manager._testFinishLaunchAgentReadinessFailure(
|
|
port: port,
|
|
startingPID: 4242)
|
|
}
|
|
await self.waitForCondition {
|
|
GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
.contains(where: { $0.first == "status" })
|
|
}
|
|
#expect(GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
.contains(where: { $0.first == "status" }))
|
|
|
|
GatewayLaunchAgentManager.setTestingDaemonCommandDelayNanoseconds(0)
|
|
manager.stop()
|
|
manager._testBeginGatewayStartGeneration()
|
|
await manager._testFinishLaunchAgentReadinessFailure(
|
|
port: port,
|
|
startingPID: 4242)
|
|
#expect(manager._testHasLaunchAgentReadinessFailure())
|
|
|
|
await staleFinish.value
|
|
|
|
#expect(manager.status == .failed("Gateway did not start in time"))
|
|
#expect(manager._testHasLaunchAgentReadinessFailure())
|
|
}
|
|
}
|
|
|
|
@Test func `repairs a stable launch agent PID with a wedged listener`() async throws {
|
|
let port = 19087
|
|
try await self.withLaunchAgentEnvironment(statusPayload: self.loadedGatewayStatus(port: port)) {
|
|
let manager = GatewayProcessManager.shared
|
|
let listener = self.gatewayDescriptor(pid: 4242)
|
|
await PortGuardian.shared.setTestingDescriptor(listener, forPort: port)
|
|
await manager._testRecordLaunchAgentReadinessFailure(port: port, startingPID: 4242)
|
|
GatewayLaunchAgentManager.clearTestingDaemonCommandCalls()
|
|
|
|
_ = await manager._testEnableLaunchAgentIfNeeded(
|
|
bundlePath: "/Applications/OpenClaw.app",
|
|
port: port)
|
|
|
|
let calls = GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
#expect(calls.filter { $0.first == "status" }.count == 1)
|
|
#expect(calls.filter { $0.first == "install" }.count == 1)
|
|
await PortGuardian.shared.setTestingDescriptor(nil, forPort: port)
|
|
}
|
|
}
|
|
|
|
@Test func `protects a foreign listener after launch agent readiness fails`() async throws {
|
|
let port = 19088
|
|
try await self.withLaunchAgentEnvironment(statusPayload: self.loadedGatewayStatus(port: port)) {
|
|
let manager = GatewayProcessManager.shared
|
|
await manager._testRecordLaunchAgentReadinessFailure(port: port, startingPID: 4242)
|
|
let listener = self.gatewayDescriptor(
|
|
pid: 4243,
|
|
command: "foreign-listener",
|
|
executablePath: "/tmp/foreign-listener")
|
|
await PortGuardian.shared.setTestingDescriptor(listener, forPort: port)
|
|
GatewayLaunchAgentManager.clearTestingDaemonCommandCalls()
|
|
|
|
_ = await manager._testEnableLaunchAgentIfNeeded(
|
|
bundlePath: "/Applications/OpenClaw.app",
|
|
port: port)
|
|
|
|
let calls = GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
#expect(calls.filter { $0.first == "status" }.count == 1)
|
|
#expect(calls.filter { $0.first == "install" }.isEmpty)
|
|
await PortGuardian.shared.setTestingDescriptor(nil, forPort: port)
|
|
}
|
|
}
|
|
|
|
@Test func `protects an unmanaged listener during persistence ensure`() async throws {
|
|
let port = 19100
|
|
try await self.withLaunchAgentEnvironment(
|
|
statusPayload: #"{"ok":true,"service":{"loaded":false}}"#)
|
|
{
|
|
let listener = self.gatewayDescriptor(
|
|
pid: 4243,
|
|
command: "manual-gateway",
|
|
executablePath: "/tmp/manual-gateway")
|
|
await PortGuardian.shared.setTestingDescriptor(listener, forPort: port)
|
|
|
|
_ = await GatewayProcessManager.shared._testEnableLaunchAgentIfNeeded(
|
|
bundlePath: "/Applications/OpenClaw.app",
|
|
port: port)
|
|
|
|
let calls = GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
#expect(calls.filter { $0.first == "status" }.count == 1)
|
|
#expect(calls.filter { $0.first == "install" }.isEmpty)
|
|
await PortGuardian.shared.setTestingDescriptor(nil, forPort: port)
|
|
}
|
|
}
|
|
|
|
@Test func `does not force install when launchd starts during ownership inspection`() async throws {
|
|
let port = 19102
|
|
let statuses = [
|
|
#"{"ok":true,"service":{"loaded":false}}"#,
|
|
self.loadedGatewayStatus(port: port),
|
|
]
|
|
try await self.withLaunchAgentEnvironment(statusPayloads: statuses) {
|
|
let listener = self.gatewayDescriptor(pid: 4242)
|
|
await PortGuardian.shared.setTestingDescriptor(listener, forPort: port)
|
|
|
|
_ = await GatewayProcessManager.shared._testEnableLaunchAgentIfNeeded(
|
|
bundlePath: "/Applications/OpenClaw.app",
|
|
port: port)
|
|
|
|
let calls = GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
#expect(calls.filter { $0.first == "status" }.count == 1)
|
|
#expect(calls.filter { $0.first == "install" }.isEmpty)
|
|
await PortGuardian.shared.setTestingDescriptor(nil, forPort: port)
|
|
}
|
|
}
|
|
|
|
@Test func `repairs loaded launch agents that are not reusable`() async throws {
|
|
let port = 19083
|
|
try await self.withLaunchAgentEnvironment {
|
|
let staleStatuses = [
|
|
"""
|
|
{"ok":true,"service":{
|
|
"loaded":true,
|
|
"runtime":{"status":"stopped"},
|
|
"command":{"programArguments":["openclaw","gateway","--port","\(port)"]},
|
|
"configAudit":{"ok":true,"issues":[]}
|
|
}}
|
|
""",
|
|
"""
|
|
{"ok":true,"service":{
|
|
"loaded":true,
|
|
"runtime":{"status":"running","pid":4242},
|
|
"command":{"programArguments":["openclaw","gateway","--port","19084"]},
|
|
"configAudit":{"ok":true,"issues":[]}
|
|
}}
|
|
""",
|
|
"""
|
|
{"ok":true,"service":{
|
|
"loaded":true,
|
|
"runtime":{"status":"running","pid":4242},
|
|
"command":{"programArguments":["openclaw","gateway","--port","\(port)"]},
|
|
"configAudit":{"ok":false,"issues":[{"code":"gateway-entrypoint-mismatch"}]}
|
|
}}
|
|
""",
|
|
]
|
|
|
|
for status in staleStatuses {
|
|
GatewayLaunchAgentManager.setTestingDaemonStatusPayload(status)
|
|
GatewayLaunchAgentManager.clearTestingDaemonCommandCalls()
|
|
|
|
_ = await GatewayProcessManager.shared._testEnableLaunchAgentIfNeeded(
|
|
bundlePath: "/Applications/OpenClaw.app",
|
|
port: port)
|
|
|
|
let calls = GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
#expect(calls.filter { $0.first == "status" }.count == 1)
|
|
#expect(calls.filter { $0.first == "install" }.count == 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test func `clears last failure when health succeeds`() async throws {
|
|
let url = try #require(URL(string: "ws://127.0.0.1:9"))
|
|
let (_, connection, manager) = self.makeGatewayReadinessFixture(url: url) {
|
|
GatewayTestWebSocketTask(
|
|
sendHook: { task, message, sendIndex in
|
|
guard sendIndex > 0 else { return }
|
|
guard let id = GatewayWebSocketTestSupport.requestID(from: message) else { return }
|
|
task.emitReceiveSuccess(.data(GatewayWebSocketTestSupport.okResponseData(id: id)))
|
|
})
|
|
}
|
|
manager.setTestingDesiredActive(true)
|
|
manager.setTestingSkipControlChannelRefresh(true)
|
|
manager.setTestingLastFailureReason("health failed")
|
|
manager.setTestingStatus(.failed("Gateway did not start in time"))
|
|
let readinessPort = GatewayEnvironment.gatewayPort()
|
|
manager._testSetLaunchAgentReadinessFailure(port: readinessPort, pid: 4242)
|
|
let descriptor = self.gatewayDescriptor(pid: 4343)
|
|
await PortGuardian.shared.setTestingDescriptor(descriptor, forPort: readinessPort)
|
|
defer {
|
|
manager.setTestingConnection(nil)
|
|
manager.setTestingDesiredActive(false)
|
|
manager.setTestingSkipControlChannelRefresh(false)
|
|
manager.setTestingLastFailureReason(nil)
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
}
|
|
|
|
let ready = await manager.waitForGatewayReady(timeout: 0.5)
|
|
#expect(ready)
|
|
#expect(manager.lastFailureReason == nil)
|
|
#expect(!manager._testHasLaunchAgentReadinessFailure())
|
|
#expect(manager.status == .running(details: "pid 4343"))
|
|
await PortGuardian.shared.setTestingDescriptor(nil, forPort: readinessPort)
|
|
}
|
|
|
|
@Test func `startup install forces the recovered control channel refresh`() async throws {
|
|
let port = try self.availableGatewayPort()
|
|
let url = try #require(URL(string: "ws://example.invalid"))
|
|
let (_, connection, manager) = self.makeGatewayReadinessFixture(url: url) {
|
|
GatewayTestWebSocketTask(
|
|
sendHook: { task, message, sendIndex in
|
|
guard sendIndex > 0 else { return }
|
|
guard let id = GatewayWebSocketTestSupport.requestID(from: message) else { return }
|
|
task.emitReceiveSuccess(.data(GatewayWebSocketTestSupport.okResponseData(id: id)))
|
|
})
|
|
}
|
|
defer { manager.setTestingConnection(nil) }
|
|
|
|
try await self.withLaunchAgentEnvironment(
|
|
port: port,
|
|
statusPayload: #"{"ok":true,"service":{"loaded":false}}"#)
|
|
{
|
|
#expect(GatewayEnvironment.gatewayPort() == port)
|
|
manager.setTestingDesiredActive(true)
|
|
manager.setTestingStatus(.attachedExisting(details: "old pid"))
|
|
manager.setTestingSkipControlChannelRefresh(true)
|
|
manager._testClearControlChannelRefreshForces()
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
defer {
|
|
manager.setTestingDesiredActive(false)
|
|
manager.setTestingSkipControlChannelRefresh(false)
|
|
manager._testClearControlChannelRefreshForces()
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
}
|
|
|
|
#expect(await manager._testEnableLaunchAgentIfNeededInstalled(
|
|
bundlePath: "/Applications/OpenClaw.app",
|
|
port: port))
|
|
let descriptor = self.gatewayDescriptor(pid: 4242)
|
|
await PortGuardian.shared.setTestingDescriptor(descriptor, forPort: port)
|
|
|
|
#expect(await manager.waitForGatewayReady(timeout: 0.5))
|
|
#expect(manager._testControlChannelRefreshForces().last == true)
|
|
#expect(manager.status == .running(details: "pid 4242"))
|
|
|
|
await connection.shutdown()
|
|
await PortGuardian.shared.setTestingDescriptor(nil, forPort: port)
|
|
}
|
|
}
|
|
|
|
@Test func `readiness refreshes when the endpoint pid changes during the probe`() async throws {
|
|
let port = GatewayEnvironment.gatewayPort()
|
|
let url = try #require(URL(string: "ws://example.invalid"))
|
|
let (_, connection, manager) = self.makeGatewayReadinessFixture(url: url) {
|
|
GatewayTestWebSocketTask(
|
|
sendHook: { task, message, sendIndex in
|
|
guard sendIndex > 0 else { return }
|
|
guard let id = GatewayWebSocketTestSupport.requestID(from: message) else { return }
|
|
let replacement = PortGuardian.Descriptor(
|
|
pid: 4343,
|
|
command: "openclaw-gateway",
|
|
executablePath: "/tmp/openclaw-gateway")
|
|
await PortGuardian.shared.setTestingDescriptor(replacement, forPort: port)
|
|
task.emitReceiveSuccess(.data(GatewayWebSocketTestSupport.okResponseData(id: id)))
|
|
})
|
|
}
|
|
manager.setTestingDesiredActive(true)
|
|
manager.setTestingStatus(.running(details: "pid 4242"))
|
|
manager.setTestingSkipControlChannelRefresh(true)
|
|
manager._testClearControlChannelRefreshForces()
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
manager._testClearLaunchAgentInstallEvidence()
|
|
manager._testSetLastObservedGatewayPID(4242)
|
|
defer {
|
|
manager.setTestingConnection(nil)
|
|
manager.setTestingDesiredActive(false)
|
|
manager.setTestingSkipControlChannelRefresh(false)
|
|
manager._testClearControlChannelRefreshForces()
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
manager._testClearLaunchAgentInstallEvidence()
|
|
manager._testSetLastObservedGatewayPID(nil)
|
|
}
|
|
|
|
let stateDir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("openclaw-gateway-pid-refresh-\(UUID().uuidString)", isDirectory: true)
|
|
defer { try? FileManager.default.removeItem(at: stateDir) }
|
|
let ready = await DeviceIdentityStore.withStateDirectory(stateDir) {
|
|
await manager.waitForGatewayReady(timeout: 0.5)
|
|
}
|
|
#expect(ready)
|
|
#expect(manager._testControlChannelRefreshForces().last == true)
|
|
#expect(manager.status == .running(details: "pid 4343"))
|
|
|
|
await connection.shutdown()
|
|
await PortGuardian.shared.setTestingDescriptor(nil, forPort: port)
|
|
}
|
|
|
|
@Test func `readiness retains the endpoint pid from before a launchd candidate`() async throws {
|
|
let port = GatewayEnvironment.gatewayPort()
|
|
let url = try #require(URL(string: "ws://127.0.0.1:9"))
|
|
let (_, connection, manager) = self.makeGatewayReadinessFixture(url: url) {
|
|
GatewayTestWebSocketTask(
|
|
sendHook: { task, message, sendIndex in
|
|
guard sendIndex > 0 else { return }
|
|
guard let id = GatewayWebSocketTestSupport.requestID(from: message) else { return }
|
|
task.emitReceiveSuccess(.data(GatewayWebSocketTestSupport.okResponseData(id: id)))
|
|
})
|
|
}
|
|
let descriptor = self.gatewayDescriptor(pid: 4242)
|
|
|
|
manager.setTestingDesiredActive(true)
|
|
manager.setTestingStatus(.running(details: "pid 4141"))
|
|
manager.setTestingSkipControlChannelRefresh(true)
|
|
manager._testClearControlChannelRefreshForces()
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
manager._testClearLaunchAgentInstallEvidence()
|
|
manager._testSetLastObservedGatewayPID(4141)
|
|
manager._testSetLaunchAgentReadinessCandidate(port: port, pid: 4242)
|
|
await PortGuardian.shared.setTestingDescriptor(descriptor, forPort: port)
|
|
defer {
|
|
manager.setTestingConnection(nil)
|
|
manager.setTestingDesiredActive(false)
|
|
manager.setTestingSkipControlChannelRefresh(false)
|
|
manager._testClearControlChannelRefreshForces()
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
manager._testClearLaunchAgentInstallEvidence()
|
|
manager._testSetLastObservedGatewayPID(nil)
|
|
}
|
|
|
|
#expect(await manager.waitForGatewayReady(timeout: 0.5))
|
|
#expect(manager._testControlChannelRefreshForces().last == true)
|
|
#expect(manager.status == .running(details: "pid 4242"))
|
|
|
|
await connection.shutdown()
|
|
await PortGuardian.shared.setTestingDescriptor(nil, forPort: port)
|
|
}
|
|
|
|
@Test func `responsive health rejection does not arm launchd repair`() async throws {
|
|
let port = 19105
|
|
let url = try #require(URL(string: "ws://example.invalid"))
|
|
let (_, connection, manager) = self.makeGatewayReadinessFixture(url: url) {
|
|
GatewayTestWebSocketTask(
|
|
sendHook: { task, message, sendIndex in
|
|
guard sendIndex > 0 else { return }
|
|
guard let id = GatewayWebSocketTestSupport.requestID(from: message) else { return }
|
|
let response = Data(
|
|
"""
|
|
{"type":"res","id":"\(id)","ok":false,
|
|
"error":{"code":"INVALID_REQUEST","message":"health rejected"}}
|
|
""".utf8)
|
|
task.emitReceiveSuccess(.data(response))
|
|
})
|
|
}
|
|
defer { manager.setTestingConnection(nil) }
|
|
|
|
try await self.withLaunchAgentEnvironment(statusPayload: self.loadedGatewayStatus(port: port)) {
|
|
manager.setTestingDesiredActive(true)
|
|
manager.setTestingStatus(.attachedExisting(details: "pid 4242"))
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
manager._testSetLaunchAgentReadinessCandidate(port: port, pid: 4242)
|
|
defer {
|
|
manager.setTestingDesiredActive(false)
|
|
manager.setTestingLastFailureReason(nil)
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
}
|
|
|
|
let descriptor = self.gatewayDescriptor(pid: 4242)
|
|
await PortGuardian.shared.setTestingDescriptor(descriptor, forPort: port)
|
|
|
|
#expect(await manager.waitForGatewayReady(timeout: 0.5) == false)
|
|
#expect(!manager._testHasLaunchAgentReadinessFailure())
|
|
guard case let .failed(reason) = manager.status else {
|
|
Issue.record("expected responsive health failure")
|
|
await connection.shutdown()
|
|
await PortGuardian.shared.setTestingDescriptor(nil, forPort: port)
|
|
return
|
|
}
|
|
#expect(reason.contains("health rejected"))
|
|
|
|
GatewayLaunchAgentManager.clearTestingDaemonCommandCalls()
|
|
_ = await manager._testEnableLaunchAgentIfNeeded(
|
|
bundlePath: "/Applications/OpenClaw.app",
|
|
port: port)
|
|
#expect(GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
.filter { $0.first == "install" }.isEmpty)
|
|
|
|
await connection.shutdown()
|
|
await PortGuardian.shared.setTestingDescriptor(nil, forPort: port)
|
|
}
|
|
}
|
|
|
|
@Test func `transient unavailable health response retries until ready`() async throws {
|
|
let stateDir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("openclaw-gateway-ready-\(UUID().uuidString)", isDirectory: true)
|
|
defer { try? FileManager.default.removeItem(at: stateDir) }
|
|
try await DeviceIdentityStore.withStateDirectory(stateDir) {
|
|
let port = GatewayEnvironment.gatewayPort()
|
|
let url = try #require(URL(string: "ws://example.invalid"))
|
|
let (_, connection, manager) = self.makeGatewayReadinessFixture(url: url) {
|
|
self.gatewayTask(healthSucceedsAfter: 1)
|
|
}
|
|
let descriptor = self.gatewayDescriptor(pid: 4242)
|
|
|
|
manager.setTestingDesiredActive(true)
|
|
manager.setTestingStatus(.starting)
|
|
manager.setTestingSkipControlChannelRefresh(true)
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
await PortGuardian.shared.setTestingDescriptor(descriptor, forPort: port)
|
|
defer {
|
|
manager.setTestingConnection(nil)
|
|
manager.setTestingDesiredActive(false)
|
|
manager.setTestingSkipControlChannelRefresh(false)
|
|
manager.setTestingLastFailureReason(nil)
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
manager._testSetLastObservedGatewayPID(nil)
|
|
}
|
|
|
|
#expect(await manager.waitForGatewayReady(timeout: 1))
|
|
#expect(manager.status == .running(details: "pid 4242"))
|
|
#expect(!manager._testHasLaunchAgentReadinessFailure())
|
|
|
|
await connection.shutdown()
|
|
await PortGuardian.shared.setTestingDescriptor(nil, forPort: port)
|
|
}
|
|
}
|
|
|
|
@Test func `readiness waiter rechecks after current owner fails past its timeout`() async throws {
|
|
let port = 19114
|
|
let url = try #require(URL(string: "ws://example.invalid"))
|
|
let (session, connection, manager) = self.makeGatewayReadinessFixture(url: url) {
|
|
self.gatewayTask(
|
|
healthSucceedsAfter: 0,
|
|
stallsFirstHealthResponse: true)
|
|
}
|
|
defer { manager.setTestingConnection(nil) }
|
|
|
|
try await self.withLaunchAgentEnvironment(
|
|
port: port,
|
|
statusPayload: self.loadedGatewayStatus(port: port))
|
|
{
|
|
manager.setTestingSkipControlChannelRefresh(true)
|
|
manager.setTestingLastFailureReason(nil)
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
let descriptor = self.gatewayDescriptor(pid: 4242)
|
|
await PortGuardian.shared.setTestingDescriptor(descriptor, forPort: port)
|
|
defer {
|
|
manager.setTestingSkipControlChannelRefresh(false)
|
|
manager.setTestingLastFailureReason(nil)
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
}
|
|
|
|
manager._testStartLaunchdGatewayReadiness(
|
|
port: port,
|
|
pid: 4242,
|
|
readinessWindow: 0.5,
|
|
firstInstallReadinessBudget: 0.5)
|
|
let readiness = Task { @MainActor in
|
|
await manager.waitForGatewayReady(timeout: 0.01)
|
|
}
|
|
|
|
await self.waitForCondition { session.snapshotMakeCount() > 0 }
|
|
#expect(session.snapshotMakeCount() > 0)
|
|
#expect(manager.status == .starting)
|
|
#expect(manager.lastFailureReason == nil)
|
|
#expect(!manager._testHasLaunchAgentReadinessFailure())
|
|
#expect(await readiness.value)
|
|
#expect(manager.status == .running(details: "pid 4242"))
|
|
#expect((session.latestTask()?.snapshotSendCount() ?? 0) > 1)
|
|
|
|
await connection.shutdown()
|
|
await PortGuardian.shared.setTestingDescriptor(nil, forPort: port)
|
|
}
|
|
}
|
|
|
|
@Test func `cancelling an owner readiness waiter preserves startup state`() async throws {
|
|
let port = 19115
|
|
let url = try #require(URL(string: "ws://example.invalid"))
|
|
let (session, connection, manager) = self.makeGatewayReadinessFixture(url: url) {
|
|
self.gatewayTask(healthSucceedsAfter: nil)
|
|
}
|
|
defer { manager.setTestingConnection(nil) }
|
|
|
|
try await self.withLaunchAgentEnvironment(
|
|
port: port,
|
|
statusPayload: self.loadedGatewayStatus(port: port))
|
|
{
|
|
manager.setTestingLastFailureReason(nil)
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
let descriptor = self.gatewayDescriptor(pid: 4242)
|
|
await PortGuardian.shared.setTestingDescriptor(descriptor, forPort: port)
|
|
|
|
manager._testStartLaunchdGatewayReadiness(
|
|
port: port,
|
|
pid: 4242,
|
|
readinessWindow: 0.5,
|
|
firstInstallReadinessBudget: 1)
|
|
let readiness = Task { @MainActor in
|
|
await manager.waitForGatewayReady(timeout: 0.01)
|
|
}
|
|
await self.waitForCondition { session.snapshotMakeCount() > 0 }
|
|
#expect(session.snapshotMakeCount() > 0)
|
|
|
|
let cancelledAt = Date()
|
|
readiness.cancel()
|
|
#expect(await readiness.value == false)
|
|
#expect(Date().timeIntervalSince(cancelledAt) < 0.5)
|
|
#expect(manager.status == .starting)
|
|
#expect(manager.lastFailureReason == nil)
|
|
#expect(!manager._testHasLaunchAgentReadinessFailure())
|
|
|
|
manager.stop()
|
|
await manager.waitForStartupAttempt()
|
|
await connection.shutdown()
|
|
await PortGuardian.shared.setTestingDescriptor(nil, forPort: port)
|
|
}
|
|
}
|
|
|
|
@Test func `new launchd gateway can cross multiple readiness deadlines`() async throws {
|
|
let port = 19116
|
|
let url = try #require(URL(string: "ws://example.invalid"))
|
|
let responseGates = [AsyncTestGate(), AsyncTestGate()]
|
|
let (session, connection, manager) = self.makeGatewayReadinessFixture(url: url) {
|
|
self.gatewayTask(
|
|
healthSucceedsAfter: 2,
|
|
healthResponseGates: responseGates)
|
|
}
|
|
defer { manager.setTestingConnection(nil) }
|
|
|
|
try await self.withLaunchAgentEnvironment(
|
|
port: port,
|
|
statusPayload: self.loadedGatewayStatus(port: port, pid: 4243))
|
|
{
|
|
manager.setTestingSkipControlChannelRefresh(true)
|
|
manager._testClearControlChannelRefreshForces()
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
let descriptor = self.gatewayDescriptor(pid: 4243)
|
|
await PortGuardian.shared.setTestingDescriptor(descriptor, forPort: port)
|
|
defer {
|
|
manager.setTestingSkipControlChannelRefresh(false)
|
|
manager._testClearControlChannelRefreshForces()
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
}
|
|
|
|
manager._testStartLaunchdGatewayReadiness(
|
|
port: port,
|
|
pid: 4242,
|
|
readinessWindow: 0.2,
|
|
firstInstallReadinessBudget: 5)
|
|
// Release each response only after its 200 ms window so the test owns
|
|
// both deadline crossings instead of depending on runner scheduling.
|
|
await self.waitForCondition { session.latestTask()?.snapshotSendCount() ?? 0 >= 2 }
|
|
#expect(session.latestTask()?.snapshotSendCount() ?? 0 >= 2)
|
|
try await Task.sleep(for: .milliseconds(250))
|
|
responseGates[0].open()
|
|
await self.waitForCondition { session.latestTask()?.snapshotSendCount() ?? 0 >= 3 }
|
|
#expect(session.latestTask()?.snapshotSendCount() ?? 0 >= 3)
|
|
try await Task.sleep(for: .milliseconds(250))
|
|
responseGates[1].open()
|
|
await manager.waitForStartupAttempt()
|
|
|
|
#expect(GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
.filter { $0.first == "status" }.count == 1)
|
|
#expect(manager.status == .running(details: "pid 4243"))
|
|
#expect(manager.lastFailureReason == nil)
|
|
#expect(!manager._testHasLaunchAgentReadinessFailure())
|
|
#expect(manager._testControlChannelRefreshForces().last == true)
|
|
|
|
await connection.shutdown()
|
|
await PortGuardian.shared.setTestingDescriptor(nil, forPort: port)
|
|
}
|
|
}
|
|
|
|
@Test func `delayed fresh install authorization cannot restart readiness budget`() async throws {
|
|
let port = 19118
|
|
let url = try #require(URL(string: "ws://example.invalid"))
|
|
let (session, connection, manager) = self.makeGatewayReadinessFixture(url: url) {
|
|
self.gatewayTask(
|
|
healthSucceedsAfter: 0,
|
|
stallsFirstHealthResponse: true)
|
|
}
|
|
defer { manager.setTestingConnection(nil) }
|
|
|
|
try await self.withLaunchAgentEnvironment(
|
|
port: port,
|
|
statusPayload: self.loadedGatewayStatus(port: port),
|
|
commandDelayNanoseconds: 100_000_000)
|
|
{
|
|
manager.setTestingLastFailureReason(nil)
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
let descriptor = self.gatewayDescriptor(pid: 4242)
|
|
await PortGuardian.shared.setTestingDescriptor(descriptor, forPort: port)
|
|
defer {
|
|
manager.setTestingLastFailureReason(nil)
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
}
|
|
|
|
manager._testStartLaunchdGatewayReadiness(
|
|
port: port,
|
|
pid: 4242,
|
|
readinessWindow: 0.01,
|
|
firstInstallReadinessBudget: 0.02)
|
|
await manager.waitForStartupAttempt()
|
|
|
|
#expect(manager.status == .failed("Gateway did not start in time"))
|
|
#expect(manager.lastFailureReason == "launchd start timeout")
|
|
#expect(manager._testHasLaunchAgentReadinessFailure())
|
|
#expect(session.latestTask()?.snapshotSendCount() == 2)
|
|
#expect(GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
.filter { $0.first == "status" }.count == 2)
|
|
|
|
await connection.shutdown()
|
|
await PortGuardian.shared.setTestingDescriptor(nil, forPort: port)
|
|
}
|
|
}
|
|
|
|
@Test func `new launchd gateway fails after bounded readiness grace`() async throws {
|
|
let port = 19117
|
|
let url = try #require(URL(string: "ws://example.invalid"))
|
|
let (_, connection, manager) = self.makeGatewayReadinessFixture(url: url) {
|
|
GatewayTestWebSocketTask()
|
|
}
|
|
defer { manager.setTestingConnection(nil) }
|
|
|
|
try await self.withLaunchAgentEnvironment(
|
|
port: port,
|
|
statusPayload: self.loadedGatewayStatus(port: port))
|
|
{
|
|
manager.setTestingLastFailureReason(nil)
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
manager._testClearLaunchAgentInstallEvidence()
|
|
let descriptor = self.gatewayDescriptor(pid: 4242)
|
|
await PortGuardian.shared.setTestingDescriptor(descriptor, forPort: port)
|
|
defer {
|
|
manager.setTestingLastFailureReason(nil)
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
manager._testClearLaunchAgentInstallEvidence()
|
|
}
|
|
|
|
manager._testStartLaunchdGatewayReadiness(
|
|
port: port,
|
|
pid: 4242,
|
|
readinessWindow: 0.05,
|
|
firstInstallReadinessBudget: 0.1)
|
|
await manager.waitForStartupAttempt()
|
|
guard case .failed("Gateway did not start in time") = manager.status else {
|
|
Issue.record("fresh launchd readiness did not fail within its bounded grace")
|
|
await connection.shutdown()
|
|
await PortGuardian.shared.setTestingDescriptor(nil, forPort: port)
|
|
return
|
|
}
|
|
|
|
#expect(manager.lastFailureReason == "launchd start timeout")
|
|
#expect(manager._testHasLaunchAgentReadinessFailure())
|
|
|
|
GatewayLaunchAgentManager.clearTestingDaemonCommandCalls()
|
|
_ = await manager._testEnableLaunchAgentIfNeeded(
|
|
bundlePath: "/Applications/OpenClaw.app",
|
|
port: port)
|
|
#expect(GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
.filter { $0.first == "install" }.count == 1)
|
|
|
|
await connection.shutdown()
|
|
await PortGuardian.shared.setTestingDescriptor(nil, forPort: port)
|
|
}
|
|
}
|
|
|
|
@Test func `cancelled readiness probe preserves lifecycle state`() async throws {
|
|
let url = try #require(URL(string: "ws://example.invalid"))
|
|
let (session, connection, manager) = self.makeGatewayReadinessFixture(url: url) {
|
|
GatewayTestWebSocketTask(
|
|
receiveHook: { _, receiveIndex in
|
|
if receiveIndex == 0 {
|
|
try await Task.sleep(nanoseconds: 30 * 1_000_000_000)
|
|
}
|
|
throw URLError(.cancelled)
|
|
})
|
|
}
|
|
manager.setTestingDesiredActive(true)
|
|
manager.setTestingStatus(.running(details: "pid 4242"))
|
|
manager.setTestingLastFailureReason("keep newer state")
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
manager._testSetLaunchAgentReadinessCandidate(port: 19106, pid: 4242)
|
|
defer {
|
|
manager.setTestingConnection(nil)
|
|
manager.setTestingDesiredActive(false)
|
|
manager.setTestingLastFailureReason(nil)
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
}
|
|
|
|
let readiness = Task { @MainActor in
|
|
await manager.waitForGatewayReady(timeout: 0.5)
|
|
}
|
|
await self.waitForCondition {
|
|
session.snapshotMakeCount() > 0
|
|
}
|
|
#expect(session.snapshotMakeCount() == 1)
|
|
readiness.cancel()
|
|
|
|
#expect(await readiness.value == false)
|
|
#expect(manager.status == .running(details: "pid 4242"))
|
|
#expect(manager.lastFailureReason == "keep newer state")
|
|
#expect(manager._testHasLaunchAgentReadinessCandidate())
|
|
await connection.shutdown()
|
|
}
|
|
|
|
@Test func `transport cancellation does not publish readiness failure`() async throws {
|
|
let url = try #require(URL(string: "ws://example.invalid"))
|
|
let (_, connection, manager) = self.makeGatewayReadinessFixture(url: url) {
|
|
GatewayTestWebSocketTask(
|
|
receiveHook: { _, _ in
|
|
throw URLError(.cancelled)
|
|
})
|
|
}
|
|
manager.setTestingDesiredActive(true)
|
|
manager.setTestingStatus(.running(details: "pid 4242"))
|
|
manager.setTestingLastFailureReason("keep current state")
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
manager._testSetLaunchAgentReadinessCandidate(port: 19113, pid: 4242)
|
|
defer {
|
|
manager.setTestingConnection(nil)
|
|
manager.setTestingDesiredActive(false)
|
|
manager.setTestingLastFailureReason(nil)
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
}
|
|
|
|
let startedAt = Date()
|
|
#expect(await manager.waitForGatewayReady(timeout: 0.5) == false)
|
|
#expect(Date().timeIntervalSince(startedAt) < 1.5)
|
|
#expect(manager.status == .running(details: "pid 4242"))
|
|
#expect(manager.lastFailureReason == "keep current state")
|
|
#expect(manager._testHasLaunchAgentReadinessCandidate())
|
|
await connection.shutdown()
|
|
}
|
|
|
|
@Test func `only endpoint reachability failures arm launchd repair`() {
|
|
let manager = GatewayProcessManager.shared
|
|
#expect(manager._testProbeFailureMayNeedLaunchAgentRepair(.timedOut))
|
|
#expect(manager._testProbeFailureMayNeedLaunchAgentRepair(.cannotConnectToHost))
|
|
#expect(manager._testProbeFailureMayNeedLaunchAgentRepair(.networkConnectionLost))
|
|
#expect(!manager._testProbeFailureMayNeedLaunchAgentRepair(.cancelled))
|
|
#expect(!manager._testProbeFailureMayNeedLaunchAgentRepair(.badServerResponse))
|
|
#expect(!manager._testProbeFailureMayNeedLaunchAgentRepair(.dataNotAllowed))
|
|
#expect(manager._testGatewayResponseRetriesWithoutRepair("UNAVAILABLE"))
|
|
#expect(!manager._testGatewayResponseRetriesWithoutRepair("INVALID_REQUEST"))
|
|
}
|
|
|
|
@Test func `stale readiness wait cannot clear a newer launch failure`() async throws {
|
|
let url = try #require(URL(string: "ws://example.invalid"))
|
|
let (session, connection, manager) = self.makeGatewayReadinessFixture(url: url) {
|
|
GatewayTestWebSocketTask(
|
|
sendHook: { task, message, sendIndex in
|
|
guard sendIndex > 0 else { return }
|
|
guard let id = GatewayWebSocketTestSupport.requestID(from: message) else { return }
|
|
task.emitReceiveSuccess(.data(GatewayWebSocketTestSupport.okResponseData(id: id)))
|
|
},
|
|
receiveHook: { _, receiveIndex in
|
|
if receiveIndex == 0 {
|
|
try await Task.sleep(nanoseconds: 100_000_000)
|
|
}
|
|
return .data(GatewayWebSocketTestSupport.connectChallengeData())
|
|
})
|
|
}
|
|
manager._testBeginGatewayStartGeneration()
|
|
defer {
|
|
manager.setTestingConnection(nil)
|
|
manager.setTestingDesiredActive(false)
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
}
|
|
|
|
let staleWait = Task { @MainActor in
|
|
await manager.waitForGatewayReady(timeout: 0.5)
|
|
}
|
|
await self.waitForCondition {
|
|
session.snapshotMakeCount() > 0
|
|
}
|
|
#expect(session.snapshotMakeCount() == 1)
|
|
manager._testBeginGatewayStartGeneration()
|
|
manager._testSetLaunchAgentReadinessFailure(port: 19101, pid: 4242)
|
|
|
|
#expect(await staleWait.value == false)
|
|
#expect(manager._testHasLaunchAgentReadinessFailure())
|
|
await connection.shutdown()
|
|
}
|
|
|
|
@Test func `same generation stale probe preserves a newer readiness candidate`() async throws {
|
|
let url = try #require(URL(string: "ws://example.invalid"))
|
|
let (session, connection, manager) = self.makeGatewayReadinessFixture(url: url) {
|
|
GatewayTestWebSocketTask(
|
|
sendHook: { task, message, sendIndex in
|
|
guard sendIndex > 0 else { return }
|
|
guard let id = GatewayWebSocketTestSupport.requestID(from: message) else { return }
|
|
task.emitReceiveSuccess(.data(GatewayWebSocketTestSupport.okResponseData(id: id)))
|
|
},
|
|
receiveHook: { _, receiveIndex in
|
|
if receiveIndex == 0 {
|
|
try await Task.sleep(nanoseconds: 100_000_000)
|
|
}
|
|
return .data(GatewayWebSocketTestSupport.connectChallengeData())
|
|
})
|
|
}
|
|
manager.setTestingDesiredActive(true)
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
manager._testSetLaunchAgentReadinessCandidate(port: 19109, pid: 4242)
|
|
defer {
|
|
manager.setTestingConnection(nil)
|
|
manager.setTestingDesiredActive(false)
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
}
|
|
|
|
let staleWait = Task { @MainActor in
|
|
await manager.waitForGatewayReady(timeout: 0.5)
|
|
}
|
|
await self.waitForCondition {
|
|
session.snapshotMakeCount() > 0
|
|
}
|
|
#expect(session.snapshotMakeCount() == 1)
|
|
manager._testSetLaunchAgentReadinessCandidate(port: 19109, pid: 4243)
|
|
|
|
#expect(await staleWait.value == false)
|
|
#expect(manager._testLaunchAgentReadinessCandidatePID() == 4243)
|
|
await connection.shutdown()
|
|
}
|
|
|
|
@Test func `same generation stale timeout preserves a newer readiness failure`() async throws {
|
|
let url = try #require(URL(string: "ws://example.invalid"))
|
|
let (session, connection, manager) = self.makeGatewayReadinessFixture(url: url) {
|
|
GatewayTestWebSocketTask(
|
|
receiveHook: { _, receiveIndex in
|
|
if receiveIndex == 0 {
|
|
try await Task.sleep(nanoseconds: 30 * 1_000_000_000)
|
|
}
|
|
return .data(GatewayWebSocketTestSupport.connectChallengeData())
|
|
})
|
|
}
|
|
manager.setTestingDesiredActive(true)
|
|
manager.setTestingLastFailureReason(nil)
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
defer {
|
|
manager.setTestingConnection(nil)
|
|
manager.setTestingDesiredActive(false)
|
|
manager.setTestingLastFailureReason(nil)
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
}
|
|
|
|
let staleWait = Task { @MainActor in
|
|
await manager.waitForGatewayReady(timeout: 0.2)
|
|
}
|
|
await self.waitForCondition {
|
|
session.snapshotMakeCount() > 0
|
|
}
|
|
#expect(session.snapshotMakeCount() == 1)
|
|
manager.setTestingLastFailureReason("newer same-generation failure")
|
|
manager._testSetLaunchAgentReadinessFailure(port: 19110, pid: 4244)
|
|
|
|
#expect(await staleWait.value == false)
|
|
#expect(manager.lastFailureReason == "newer same-generation failure")
|
|
#expect(manager._testHasLaunchAgentReadinessFailure())
|
|
await connection.shutdown()
|
|
}
|
|
|
|
@Test func `stale readiness timeout cannot replace a newer launch failure`() async throws {
|
|
let url = try #require(URL(string: "ws://example.invalid"))
|
|
let (session, connection, manager) = self.makeGatewayReadinessFixture(url: url) {
|
|
GatewayTestWebSocketTask(
|
|
receiveHook: { _, receiveIndex in
|
|
if receiveIndex == 0 {
|
|
try await Task.sleep(nanoseconds: 30 * 1_000_000_000)
|
|
}
|
|
return .data(GatewayWebSocketTestSupport.connectChallengeData())
|
|
})
|
|
}
|
|
manager._testBeginGatewayStartGeneration()
|
|
defer {
|
|
manager.setTestingConnection(nil)
|
|
manager.setTestingDesiredActive(false)
|
|
manager.setTestingLastFailureReason(nil)
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
}
|
|
|
|
let staleWait = Task { @MainActor in
|
|
await manager.waitForGatewayReady(timeout: 0.5)
|
|
}
|
|
await self.waitForCondition {
|
|
session.snapshotMakeCount() > 0
|
|
}
|
|
#expect(session.snapshotMakeCount() == 1)
|
|
manager._testBeginGatewayStartGeneration()
|
|
manager.setTestingLastFailureReason("newer command resolution failure")
|
|
manager._testSetLaunchAgentReadinessFailure(port: 19103, pid: 4243)
|
|
|
|
#expect(await staleWait.value == false)
|
|
#expect(manager.lastFailureReason == "newer command resolution failure")
|
|
#expect(manager._testHasLaunchAgentReadinessFailure())
|
|
await connection.shutdown()
|
|
}
|
|
|
|
@Test func `readiness timeout includes a stalled socket connect`() async throws {
|
|
let url = try #require(URL(string: "ws://example.invalid"))
|
|
let (session, connection, manager) = self.makeGatewayReadinessFixture(url: url) {
|
|
GatewayTestWebSocketTask(
|
|
receiveHook: { _, receiveIndex in
|
|
if receiveIndex == 0 {
|
|
try await Task.sleep(nanoseconds: 30 * 1_000_000_000)
|
|
}
|
|
return .data(GatewayWebSocketTestSupport.connectChallengeData())
|
|
})
|
|
}
|
|
manager.setTestingDesiredActive(true)
|
|
manager.setTestingStatus(.attachedExisting(details: "pid 3131"))
|
|
manager.setTestingLastFailureReason(nil)
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
manager._testSetLaunchAgentReadinessFailure(port: 19111, pid: 4245)
|
|
defer {
|
|
manager.setTestingConnection(nil)
|
|
manager.setTestingDesiredActive(false)
|
|
manager.setTestingLastFailureReason(nil)
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
}
|
|
|
|
let startedAt = Date()
|
|
let ready = await manager.waitForGatewayReady(timeout: 0.1)
|
|
let elapsed = Date().timeIntervalSince(startedAt)
|
|
await connection.shutdown()
|
|
|
|
#expect(!ready)
|
|
#expect(elapsed < 1)
|
|
#expect(session.snapshotMakeCount() == 1)
|
|
#expect(manager.status == .failed("Gateway did not start in time"))
|
|
#expect(manager.lastFailureReason == "gateway readiness timeout")
|
|
#expect(manager._testHasLaunchAgentReadinessFailure())
|
|
}
|
|
|
|
@Test func `readiness timeout preserves a concrete launch failure`() async throws {
|
|
let url = try #require(URL(string: "ws://example.invalid"))
|
|
let (_, connection, manager) = self.makeGatewayReadinessFixture(url: url) {
|
|
GatewayTestWebSocketTask(
|
|
receiveHook: { _, receiveIndex in
|
|
if receiveIndex == 0 {
|
|
try await Task.sleep(nanoseconds: 30 * 1_000_000_000)
|
|
}
|
|
return .data(GatewayWebSocketTestSupport.connectChallengeData())
|
|
})
|
|
}
|
|
manager.setTestingDesiredActive(true)
|
|
manager.setTestingStatus(.failed("launchd install denied"))
|
|
manager.setTestingLastFailureReason("launchd install denied")
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
defer {
|
|
manager.setTestingConnection(nil)
|
|
manager.setTestingDesiredActive(false)
|
|
manager.setTestingLastFailureReason(nil)
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
}
|
|
|
|
#expect(await manager.waitForGatewayReady(timeout: 0.1) == false)
|
|
#expect(manager.status == .failed("launchd install denied"))
|
|
#expect(manager.lastFailureReason == "launchd install denied")
|
|
await connection.shutdown()
|
|
}
|
|
|
|
@Test func `replacement readiness timeout records the pid for the next repair`() async throws {
|
|
let port = 19104
|
|
let manager = GatewayProcessManager.shared
|
|
|
|
try await self.withLaunchAgentEnvironment(statusPayload: self.loadedGatewayStatus(port: port)) {
|
|
manager.setTestingDesiredActive(true)
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
defer {
|
|
manager.setTestingDesiredActive(false)
|
|
manager.setTestingLastFailureReason(nil)
|
|
manager._testClearLaunchAgentReadinessFailure()
|
|
}
|
|
|
|
let listener = self.gatewayDescriptor(pid: 4242)
|
|
await PortGuardian.shared.setTestingDescriptor(listener, forPort: port)
|
|
|
|
_ = await manager._testEnableLaunchAgentIfNeeded(
|
|
bundlePath: "/Applications/OpenClaw.app",
|
|
port: port)
|
|
#expect(GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
.filter { $0.first == "install" }.isEmpty)
|
|
#expect(manager._testHasLaunchAgentReadinessCandidate())
|
|
|
|
await manager._testFinishGatewayReadinessTimeout()
|
|
#expect(manager._testHasLaunchAgentReadinessFailure())
|
|
|
|
GatewayLaunchAgentManager.clearTestingDaemonCommandCalls()
|
|
_ = await manager._testEnableLaunchAgentIfNeeded(
|
|
bundlePath: "/Applications/OpenClaw.app",
|
|
port: port)
|
|
#expect(GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
|
.filter { $0.first == "install" }.count == 1)
|
|
|
|
await PortGuardian.shared.setTestingDescriptor(nil, forPort: port)
|
|
}
|
|
}
|
|
|
|
@Test func `attaches to existing gateway without spawning launchd`() async throws {
|
|
let port = 19097
|
|
do {
|
|
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 url = try #require(URL(string: "ws://example.invalid"))
|
|
let (_, connection, manager) = self.makeGatewayReadinessFixture(url: url) {
|
|
GatewayTestWebSocketTask(
|
|
sendHook: { task, message, sendIndex in
|
|
guard sendIndex > 0 else { return }
|
|
guard let id = GatewayWebSocketTestSupport.requestID(from: message) else { return }
|
|
if sendIndex == 1 {
|
|
let response = Data(
|
|
"""
|
|
{"type":"res","id":"\(id)","ok":false,
|
|
"error":{"code":"UNAVAILABLE","message":"gateway restarting"}}
|
|
""".utf8)
|
|
task.emitReceiveSuccess(.data(response))
|
|
return
|
|
}
|
|
let replacement = PortGuardian.Descriptor(
|
|
pid: 4343,
|
|
command: "openclaw-gateway",
|
|
executablePath: "/tmp/openclaw-gateway")
|
|
await PortGuardian.shared.setTestingDescriptor(replacement, forPort: port)
|
|
let json = """
|
|
{
|
|
"type": "res",
|
|
"id": "\(id)",
|
|
"ok": true,
|
|
"payload": \(String(decoding: healthData, as: UTF8.self))
|
|
}
|
|
"""
|
|
task.emitReceiveSuccess(.data(Data(json.utf8)))
|
|
})
|
|
}
|
|
let descriptor = self.gatewayDescriptor(pid: 4242)
|
|
|
|
await PortGuardian.shared.setTestingDescriptor(descriptor, forPort: port)
|
|
manager.setTestingSkipControlChannelRefresh(true)
|
|
manager.setTestingLastFailureReason("stale")
|
|
manager._testClearControlChannelRefreshForces()
|
|
manager._testSetLastObservedGatewayPID(4242)
|
|
|
|
@MainActor
|
|
func cleanup() async {
|
|
manager.setTestingConnection(nil)
|
|
manager.setTestingSkipControlChannelRefresh(false)
|
|
manager.setTestingDesiredActive(false)
|
|
manager.setTestingLastFailureReason(nil)
|
|
manager._testClearControlChannelRefreshForces()
|
|
manager._testSetLastObservedGatewayPID(nil)
|
|
await PortGuardian.shared.setTestingDescriptor(nil, forPort: port)
|
|
}
|
|
|
|
do {
|
|
let attached = await manager._testAttachExistingGatewayIfAvailable(port: port)
|
|
#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 4343 openclaw-gateway @ /tmp/openclaw-gateway"))
|
|
#expect(manager._testControlChannelRefreshForces().last == true)
|
|
await cleanup()
|
|
} catch {
|
|
await cleanup()
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
}
|