From ee30bb46c2bfd5866452a6a4800111673e0d616d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 8 Aug 2026 21:22:14 -0700 Subject: [PATCH] fix(macos): keep onboarding alive during Local Network permission (#120859) * fix(macos): wait for Gateway startup owner * chore(i18n): refresh macOS source inventory * fix(macos): bound first-install readiness grace --- apps/.i18n/native-source.json | 10 +- .../OpenClaw/GatewayProcessManager.swift | 198 ++++++++++-- .../GatewayProcessManagerTests.swift | 283 ++++++++++++++++-- 3 files changed, 433 insertions(+), 58 deletions(-) diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index f7c4c7998aef..06632ed9a8e8 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -32939,7 +32939,7 @@ }, { "kind": "conditional-branch", - "line": 78, + "line": 77, "path": "apps/macos/Sources/OpenClaw/GatewayProcessManager.swift", "source": "Stopped", "surface": "apple", @@ -32947,7 +32947,7 @@ }, { "kind": "conditional-branch", - "line": 79, + "line": 78, "path": "apps/macos/Sources/OpenClaw/GatewayProcessManager.swift", "source": "Starting…", "surface": "apple", @@ -32955,7 +32955,7 @@ }, { "kind": "conditional-branch", - "line": 88, + "line": 87, "path": "apps/macos/Sources/OpenClaw/GatewayProcessManager.swift", "source": "Failed: \\(reason)", "surface": "apple", @@ -32963,7 +32963,7 @@ }, { "kind": "conditional-branch", - "line": 578, + "line": 598, "path": "apps/macos/Sources/OpenClaw/GatewayProcessManager.swift", "source": "not linked", "surface": "apple", @@ -32971,7 +32971,7 @@ }, { "kind": "conditional-branch", - "line": 591, + "line": 611, "path": "apps/macos/Sources/OpenClaw/GatewayProcessManager.swift", "source": "unknown error", "surface": "apple", diff --git a/apps/macos/Sources/OpenClaw/GatewayProcessManager.swift b/apps/macos/Sources/OpenClaw/GatewayProcessManager.swift index 666bd39008e7..ca731e99787d 100644 --- a/apps/macos/Sources/OpenClaw/GatewayProcessManager.swift +++ b/apps/macos/Sources/OpenClaw/GatewayProcessManager.swift @@ -55,7 +55,6 @@ final class GatewayProcessManager { private struct LaunchAgentStartupContext { let port: Int - let enableResult: LaunchAgentEnableResult let readinessPID: Int32? let readinessRevision: UInt64 } @@ -113,6 +112,7 @@ final class GatewayProcessManager { private var launchAgentReadinessCandidate: LaunchAgentReadinessCandidate? private var launchAgentReadinessRevision: UInt64 = 0 private var launchAgentInstallGeneration: UInt64? + private var launchAgentFreshInstallGeneration: UInt64? private var lastObservedGatewayPID: Int32? /// Async readiness audits may outlive stop/restart. Only the current generation may publish /// their failure state or retain a PID for a later repair. @@ -278,6 +278,7 @@ final class GatewayProcessManager { } } + var isReadinessRepair = false if let pid = launchAgent.reusablePID { let failure = LaunchAgentReadinessFailure(port: request.port, pid: pid) if self.launchAgentReadinessFailure != failure { @@ -291,6 +292,7 @@ final class GatewayProcessManager { return .skipped } + isReadinessRepair = true self.appendLog( "[gateway] launchd pid \(pid) failed readiness on port \(request.port); repairing\n") self.logger.warning( @@ -309,6 +311,7 @@ final class GatewayProcessManager { // Keep replacement evidence until a healthy audit refreshes the control channel. Startup // and persistence calls coalesce, so the later caller may not receive `installed` itself. self.launchAgentInstallGeneration = request.generation + self.launchAgentFreshInstallGeneration = isReadinessRepair ? nil : request.generation return .installed() } @@ -317,17 +320,24 @@ final class GatewayProcessManager { startingPID: Int32?) async -> LaunchAgentReadinessFailure? { guard let startingPID, - let pid = await GatewayLaunchAgentManager.reusableLoadedGatewayPID(port: port), + let pid = await self.reusableLaunchdPIDOwningPort(port: port), pid == startingPID else { return nil } + return LaunchAgentReadinessFailure(port: port, pid: pid) + } + + private func reusableLaunchdPIDOwningPort(port: Int) async -> Int32? { + guard let pid = await GatewayLaunchAgentManager.reusableLoadedGatewayPID(port: port) else { + return nil + } // A stable launchd PID that owns the port can still have a wedged health RPC. A listener // owned by anyone else is protected and surfaced through the attach path instead. if let listener = await PortGuardian.shared.describe(port: port), listener.pid != pid { return nil } - return LaunchAgentReadinessFailure(port: port, pid: pid) + return pid } private func setLaunchAgentReadinessState( @@ -369,21 +379,30 @@ final class GatewayProcessManager { self.logger.debug("gateway start requested") // First try to latch onto an already-running gateway to avoid spawning a duplicate. - let task = Task { [weak self] in + self.beginGatewayStartTask(generation: startGeneration) { [weak self] in guard let self else { return } - defer { - if self.gatewayStartTaskGeneration == startGeneration { - self.gatewayStartTask = nil - self.gatewayStartTaskGeneration = nil - } - } if await self.attachExistingGatewayAfterPendingDisable(startGeneration: startGeneration) { return } await self.enableLaunchdGateway(startGeneration: startGeneration) } - self.gatewayStartTaskGeneration = startGeneration - self.gatewayStartTask = task + } + + private func beginGatewayStartTask( + generation: UInt64, + operation: @escaping @MainActor @Sendable () async -> Void) + { + self.gatewayStartTaskGeneration = generation + self.gatewayStartTask = Task { @MainActor [weak self] in + guard let self else { return } + defer { + if self.gatewayStartTaskGeneration == generation { + self.gatewayStartTask = nil + self.gatewayStartTaskGeneration = nil + } + } + await operation() + } } func waitForStartupAttempt() async { @@ -402,6 +421,7 @@ final class GatewayProcessManager { self.lastFailureReason = nil self.setLaunchAgentReadinessState(candidate: nil, failure: nil) self.launchAgentInstallGeneration = nil + self.launchAgentFreshInstallGeneration = nil // Queued work belongs to the previous lifecycle. The active enable cannot be cancelled // safely, so the disable waits for its drain and wins unless a newer start supersedes it. self.launchAgentEnablePendingRequest = nil @@ -663,7 +683,6 @@ extension GatewayProcessManager { guard self.isCurrentGatewayStart(startGeneration) else { return nil } return LaunchAgentStartupContext( port: port, - enableResult: enableResult, readinessPID: readinessPID, readinessRevision: self.launchAgentReadinessRevision) } @@ -672,12 +691,48 @@ extension GatewayProcessManager { guard let context = await self.prepareLaunchdGatewayStart(startGeneration: startGeneration) else { return } - // Best-effort: wait for the gateway to accept connections. - let deadline = Date().addingTimeInterval(6) + await self.observeLaunchdGatewayReadiness(context: context, startGeneration: startGeneration) + } + + private func observeLaunchdGatewayReadiness( + context: LaunchAgentStartupContext, + startGeneration: UInt64, + readinessWindow: TimeInterval = 6, + // A fresh install gets ten six-second probe windows for Local Network authorization. + firstInstallReadinessGraceWindows: Int = 9) async + { + let startedAt = Date() + var deadline = startedAt.addingTimeInterval(readinessWindow) + let graceWindowCount = max(0, firstInstallReadinessGraceWindows) + let finalProbeDeadline = startedAt.addingTimeInterval( + readinessWindow * (Double(graceWindowCount) + 1)) var latestRetryDisposition: GatewayProbeFailureDisposition? - while Date() < deadline { - guard !Task.isCancelled else { return } - guard self.isCurrentGatewayStart(startGeneration) else { return } + var readinessPID = context.readinessPID + var freshInstallGraceAuthorized = false + readinessLoop: while true { + guard !Task.isCancelled, self.isCurrentGatewayStart(startGeneration) else { return } + while Date() >= deadline { + guard deadline < finalProbeDeadline else { break readinessLoop } + if freshInstallGraceAuthorized { + // Repeating launchd status at every boundary would expand the wall-clock budget. + // Generation/revision guard intermediate windows; success and final repair re-check ownership. + guard self.isCurrentFreshInstallReadiness( + context: context, + startGeneration: startGeneration) + else { return } + } else { + guard let reusablePID = await self.currentInstallReusableLaunchdPID( + context: context, + startGeneration: startGeneration) + else { break readinessLoop } + readinessPID = reusablePID + freshInstallGraceAuthorized = true + } + deadline = min( + deadline.addingTimeInterval(readinessWindow), + finalProbeDeadline) + guard Date() < finalProbeDeadline else { break readinessLoop } + } do { let remainingMs = max(1, deadline.timeIntervalSinceNow * 1000) _ = try await self.probeGatewayHealth(timeoutMs: min(1500, remainingMs)) @@ -715,13 +770,11 @@ extension GatewayProcessManager { } } - // Only a PID that survived this entire readiness cycle may be replaced later. launchd can - // restart the service while polling; that replacement needs its own full startup chance. - guard !Task.isCancelled else { return } + guard !Task.isCancelled, self.isCurrentGatewayStart(startGeneration) else { return } if latestRetryDisposition == .retryWithRepair { await self.finishLaunchAgentReadinessFailure( port: context.port, - startingPID: context.readinessPID, + startingPID: readinessPID, startGeneration: startGeneration, expectedReadinessRevision: context.readinessRevision) } else { @@ -731,6 +784,34 @@ extension GatewayProcessManager { } } + private func currentInstallReusableLaunchdPID( + context: LaunchAgentStartupContext, + startGeneration: UInt64) async -> Int32? + { + guard self.isCurrentFreshInstallReadiness( + context: context, + startGeneration: startGeneration) + else { return nil } + guard let reusablePID = await self.reusableLaunchdPIDOwningPort(port: context.port) else { + return nil + } + guard self.isCurrentFreshInstallReadiness( + context: context, + startGeneration: startGeneration) + else { return nil } + return reusablePID + } + + private func isCurrentFreshInstallReadiness( + context: LaunchAgentStartupContext, + startGeneration: UInt64) -> Bool + { + !Task.isCancelled && + self.launchAgentFreshInstallGeneration == startGeneration && + self.isCurrentGatewayStart(startGeneration) && + self.launchAgentReadinessRevision == context.readinessRevision + } + private func publishLaunchdGatewayReady( instance: PortGuardian.Descriptor?, context: LaunchAgentStartupContext, @@ -750,8 +831,7 @@ extension GatewayProcessManager { let previouslyObservedPIDChanged = Self.gatewayPIDChanged( from: self.lastObservedGatewayPID, to: instance?.pid) - let launchAgentReplaced = context.enableResult.installed || - self.launchAgentInstallGeneration == startGeneration || + let launchAgentReplaced = self.launchAgentInstallGeneration == startGeneration || endpointPIDChanged || previouslyObservedPIDChanged self.setLaunchAgentReadinessState(candidate: nil, failure: nil) @@ -765,6 +845,9 @@ extension GatewayProcessManager { if self.launchAgentInstallGeneration == startGeneration { self.launchAgentInstallGeneration = nil } + if self.launchAgentFreshInstallGeneration == startGeneration { + self.launchAgentFreshInstallGeneration = nil + } self.refreshLog() return true } @@ -900,6 +983,7 @@ extension GatewayProcessManager { launchAgentInstalled: Bool = false) async -> Bool { let startGeneration = self.gatewayStartGeneration + if let result = await self.observeCurrentGatewayStart(generation: startGeneration) { return result } let readinessCandidate = self.launchAgentReadinessCandidate let readinessFailure = self.launchAgentReadinessFailure let readinessRevision = self.launchAgentReadinessRevision @@ -931,7 +1015,7 @@ extension GatewayProcessManager { case .fail: await self.finishResponsiveGatewayProbeFailure( error, - port: readinessCandidate?.failure.port ?? GatewayEnvironment.gatewayPort(), + port: readinessPort, startGeneration: startGeneration, expectedCandidate: readinessCandidate, expectedReadinessRevision: readinessRevision) @@ -957,6 +1041,20 @@ extension GatewayProcessManager { return false } + private func observeCurrentGatewayStart(generation: UInt64) async -> Bool? { + guard self.gatewayStartTaskGeneration == generation else { return nil } + while self.gatewayStartTaskGeneration == generation { + // Cancellation interrupts the sleep, so a waiter can leave without touching the owner. + try? await Task.sleep(nanoseconds: 100_000_000) + guard !Task.isCancelled, self.isCurrentGatewayStart(generation) else { return false } + } + guard !Task.isCancelled, self.isCurrentGatewayStart(generation) else { return false } + return switch self.status { + case .running, .attachedExisting: true + case .stopped, .starting, .failed: false + } + } + private func publishGatewayReadinessSuccess( instance: PortGuardian.Descriptor?, startGeneration: UInt64, @@ -995,6 +1093,9 @@ extension GatewayProcessManager { if self.launchAgentInstallGeneration == startGeneration { self.launchAgentInstallGeneration = nil } + if self.launchAgentFreshInstallGeneration == startGeneration { + self.launchAgentFreshInstallGeneration = nil + } self.refreshLog() return true } @@ -1099,6 +1200,11 @@ extension GatewayProcessManager { func _testClearLaunchAgentInstallEvidence() { self.launchAgentInstallGeneration = nil + self.launchAgentFreshInstallGeneration = nil + } + + func _testHasLaunchAgentFreshInstallEvidence() -> Bool { + self.launchAgentFreshInstallGeneration != nil } func _testSetLastObservedGatewayPID(_ pid: Int32?) { @@ -1209,5 +1315,47 @@ extension GatewayProcessManager { func _testPendingLaunchAgentPort() -> Int? { self.launchAgentEnablePendingRequest?.port } + + func _testResetGatewayStartTask() { + self.desiredActive = false + self.gatewayStartGeneration &+= 1 + self.gatewayStartTask?.cancel() + self.gatewayStartTask = nil + self.gatewayStartTaskGeneration = nil + } + + func _testFinishGatewayReadinessTimeout() async { + await self.finishGatewayReadinessTimeout( + startGeneration: self.gatewayStartGeneration, + readinessCandidate: self.launchAgentReadinessCandidate, + readinessFailure: self.launchAgentReadinessFailure, + readinessRevision: self.launchAgentReadinessRevision, + latestRetryDisposition: .retryWithRepair) + } + + func _testStartLaunchdGatewayReadiness( + port: Int, + pid: Int32, + readinessWindow: TimeInterval, + firstInstallReadinessGraceWindows: Int) + { + self.desiredActive = true + self.status = .starting + self.gatewayStartGeneration &+= 1 + let generation = self.gatewayStartGeneration + let readinessRevision = self.launchAgentReadinessRevision + self.launchAgentInstallGeneration = generation + self.launchAgentFreshInstallGeneration = generation + self.beginGatewayStartTask(generation: generation) { [weak self] in + await self?.observeLaunchdGatewayReadiness( + context: LaunchAgentStartupContext( + port: port, + readinessPID: pid, + readinessRevision: readinessRevision), + startGeneration: generation, + readinessWindow: readinessWindow, + firstInstallReadinessGraceWindows: firstInstallReadinessGraceWindows) + } + } } #endif diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift index 9be9eb9a3ba4..7aa128ca73e7 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift @@ -83,6 +83,7 @@ struct GatewayProcessManagerTests { GatewayLaunchAgentManager.clearTestingDaemonCommandCalls() GatewayProcessManager.shared.setTestingDesiredActive(false) GatewayProcessManager.shared._testClearLaunchAgentReadinessFailure() + GatewayProcessManager.shared._testClearLaunchAgentInstallEvidence() } return try await body() } @@ -98,6 +99,7 @@ struct GatewayProcessManagerTests { configProvider: { (url: url, token: nil, password: nil) }, sessionBox: WebSocketSessionBox(session: session)) let manager = GatewayProcessManager.shared + manager._testResetGatewayStartTask() manager.setTestingConnection(connection) return (session, connection, manager) } @@ -110,6 +112,28 @@ struct GatewayProcessManagerTests { PortGuardian.Descriptor(pid: pid, command: command, executablePath: executablePath) } + private nonisolated func gatewayTask( + healthSucceedsAfter unavailableResponses: Int?, + stallsFirstHealthResponse: Bool = false) -> GatewayTestWebSocketTask + { + GatewayTestWebSocketTask( + sendHook: { task, message, sendIndex in + guard sendIndex > 0 else { return } + guard let id = GatewayWebSocketTestSupport.requestID(from: message) else { return } + 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, @@ -483,6 +507,7 @@ struct GatewayProcessManagerTests { calls = GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot() #expect(calls.filter { $0.first == "status" }.count == 1) #expect(calls.filter { $0.first == "install" }.count == 1) + #expect(!manager._testHasLaunchAgentFreshInstallEvidence()) } } @@ -928,31 +953,19 @@ struct GatewayProcessManagerTests { 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 } - if sendIndex == 1 { - let response = Data( - """ - {"type":"res","id":"\(id)","ok":false, - "error":{"code":"UNAVAILABLE","message":"gateway restarting"}} - """.utf8) - task.emitReceiveSuccess(.data(response)) - return - } - task.emitReceiveSuccess(.data(GatewayWebSocketTestSupport.okResponseData(id: id))) - }) + 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) @@ -967,6 +980,230 @@ struct GatewayProcessManagerTests { } } + @Test func `readiness waiter observes the current owner 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: 1) + } + 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, + firstInstallReadinessGraceWindows: 1) + 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")) + + 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, + firstInstallReadinessGraceWindows: 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 (_, connection, manager) = self.makeGatewayReadinessFixture(url: url) { + self.gatewayTask(healthSucceedsAfter: 2) + } + 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.05, + firstInstallReadinessGraceWindows: 2) + 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, + firstInstallReadinessGraceWindows: 1) + 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, + firstInstallReadinessGraceWindows: 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) { @@ -1263,17 +1500,7 @@ struct GatewayProcessManagerTests { @Test func `replacement readiness timeout records the pid for the next repair`() async throws { let port = 19104 - 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()) - }) - } - defer { manager.setTestingConnection(nil) } + let manager = GatewayProcessManager.shared try await self.withLaunchAgentEnvironment(statusPayload: self.loadedGatewayStatus(port: port)) { manager.setTestingDesiredActive(true) @@ -1292,8 +1519,9 @@ struct GatewayProcessManagerTests { port: port) #expect(GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot() .filter { $0.first == "install" }.isEmpty) + #expect(manager._testHasLaunchAgentReadinessCandidate()) - #expect(await manager.waitForGatewayReady(timeout: 0.1) == false) + await manager._testFinishGatewayReadinessTimeout() #expect(manager._testHasLaunchAgentReadinessFailure()) GatewayLaunchAgentManager.clearTestingDaemonCommandCalls() @@ -1303,7 +1531,6 @@ struct GatewayProcessManagerTests { #expect(GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot() .filter { $0.first == "install" }.count == 1) - await connection.shutdown() await PortGuardian.shared.setTestingDescriptor(nil, forPort: port) } }