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
This commit is contained in:
Peter Steinberger
2026-08-08 21:22:14 -07:00
committed by GitHub
parent 26ee1b4935
commit ee30bb46c2
3 changed files with 433 additions and 58 deletions
+5 -5
View File
@@ -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",
@@ -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
@@ -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)
}
}