mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
26ee1b4935
commit
ee30bb46c2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user