Files
openclaw/apps/macos/Sources/OpenClaw/GatewayProcessManager.swift
Peter Steinberger 167a8ef20a fix(onboarding): harden fresh local startup and activation (#108764)
* fix(macos): preserve gateway during fresh setup

* fix(macos): validate reusable gateway service

* chore(macos): sync native i18n inventory

* fix(macos): preserve latest gateway service request

* test(macos): isolate gateway port concurrency coverage

* fix(macos): recover gateways after failed readiness

* fix(macos): repair wedged gateway listeners

* fix(macos): bound gateway startup probes

* chore(macos): sync native i18n inventory

* fix(onboarding): activate verified inference immediately

* fix(macos): isolate readiness failure generations

* chore(macos): refresh native i18n inventory

* fix(macos): serialize gateway service lifecycle

* fix(macos): sequence gateway startup persistence

* fix(macos): close gateway readiness races

* fix(macos): publish recovered gateway state

* fix(macos): make gateway readiness generation-safe

* chore(macos): refresh native i18n inventory

* fix(macos): preserve gateway endpoint identity

* fix(macos): refresh gateway ownership after attach

* fix(macos): distinguish transient gateway readiness

* refactor(macos): split gateway readiness lifecycle

* chore(macos): refresh native i18n inventory

* chore(release): keep changelog release-owned
2026-07-16 17:24:53 -07:00

1209 lines
51 KiB
Swift

import Foundation
import Observation
import OpenClawKit
private struct GatewayHealthProbeTimeout: LocalizedError, Sendable {
let timeoutMs: Double
var errorDescription: String? {
"Gateway health probe timed out after \(Int(self.timeoutMs))ms"
}
}
@MainActor
@Observable
final class GatewayProcessManager {
static let shared = GatewayProcessManager()
private struct LaunchAgentEnableRequest: Sendable {
let bundlePath: String
let port: Int
let generation: UInt64
var invocationIDs: [UInt64]
func hasSameConfiguration(as other: LaunchAgentEnableRequest) -> Bool {
self.bundlePath == other.bundlePath &&
self.port == other.port &&
self.generation == other.generation
}
}
private struct LaunchAgentReadinessFailure: Equatable {
let port: Int
let pid: Int32
}
private struct LaunchAgentReadinessCandidate: Equatable {
let failure: LaunchAgentReadinessFailure
let generation: UInt64
}
private struct LaunchAgentEnableResult: Sendable {
let error: String?
let installed: Bool
static let skipped = LaunchAgentEnableResult(error: nil, installed: false)
static func failed(_ error: String) -> LaunchAgentEnableResult {
LaunchAgentEnableResult(error: error, installed: false)
}
static func installed() -> LaunchAgentEnableResult {
LaunchAgentEnableResult(error: nil, installed: true)
}
}
private struct LaunchAgentStartupContext {
let port: Int
let enableResult: LaunchAgentEnableResult
let readinessPID: Int32?
let readinessRevision: UInt64
}
private enum GatewayProbeFailureDisposition: Equatable {
case retryWithoutRepair
case retryWithRepair
case fail
}
enum Status: Equatable {
case stopped
case starting
case running(details: String?)
case attachedExisting(details: String?)
case failed(String)
var label: String {
switch self {
case .stopped: return "Stopped"
case .starting: return "Starting…"
case let .running(details):
if let details, !details.isEmpty { return "Running (\(details))" }
return "Running"
case let .attachedExisting(details):
if let details, !details.isEmpty {
return "Using existing gateway (\(details))"
}
return "Using existing gateway"
case let .failed(reason): return "Failed: \(reason)"
}
}
}
private(set) var status: Status = .stopped {
didSet { CanvasManager.shared.refreshDebugStatus() }
}
private(set) var log: String = ""
private(set) var environmentStatus: GatewayEnvironmentStatus = .checking
private(set) var existingGatewayDetails: String?
private(set) var lastFailureReason: String?
private var desiredActive = false
private var environmentRefreshTask: Task<Void, Never>?
private var lastEnvironmentRefresh: Date?
private var logRefreshTask: Task<Void, Never>?
private var launchAgentEnableTask: Task<[UInt64: LaunchAgentEnableResult], Never>?
private var launchAgentEnableCurrentRequest: LaunchAgentEnableRequest?
private var launchAgentEnablePendingRequest: LaunchAgentEnableRequest?
private var launchAgentEnableSupersededInvocationIDs: Set<UInt64> = []
private var launchAgentEnableNextInvocationID: UInt64 = 0
private var launchAgentDisableTask: Task<Void, Never>?
private var launchAgentDisableGeneration: UInt64?
private var launchAgentReadinessFailure: LaunchAgentReadinessFailure?
private var launchAgentReadinessCandidate: LaunchAgentReadinessCandidate?
private var launchAgentReadinessRevision: UInt64 = 0
private var launchAgentInstallGeneration: 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.
private var gatewayStartGeneration: UInt64 = 0
private var gatewayStartTask: Task<Void, Never>?
private var gatewayStartTaskGeneration: UInt64?
#if DEBUG
private var testingConnection: GatewayConnection?
private var testingSkipControlChannelRefresh = false
private var testingControlChannelRefreshForces: [Bool] = []
#endif
private let logger = Logger(subsystem: "ai.openclaw", category: "gateway.process")
private let logLimit = 20000 // characters to keep in-memory
private let environmentRefreshMinInterval: TimeInterval = 30
private var connection: GatewayConnection {
#if DEBUG
return self.testingConnection ?? .shared
#else
return .shared
#endif
}
func setActive(_ active: Bool) {
// Remote mode should never spawn a local gateway; treat as stopped.
if CommandResolver.connectionModeIsRemote() {
self.desiredActive = false
self.stop()
self.status = .stopped
self.appendLog("[gateway] remote mode active; skipping local gateway\n")
self.logger.info("gateway process skipped: remote mode active")
return
}
self.logger.debug("gateway active requested active=\(active)")
self.desiredActive = active
self.refreshEnvironmentStatus()
if active {
self.startIfNeeded()
} else {
self.stop()
}
}
func ensureLaunchAgentEnabledIfNeeded() async -> Bool {
guard !CommandResolver.connectionModeIsRemote() else { return false }
guard self.desiredActive else { return false }
if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() {
self.appendLog("[gateway] launchd auto-enable skipped (attach-only)\n")
self.logger.info("gateway launchd auto-enable skipped (disable marker set)")
return false
}
let bundlePath = Bundle.main.bundleURL.path
let port = GatewayEnvironment.gatewayPort()
let result = await self.enableLaunchAgentIfNeeded(
bundlePath: bundlePath,
port: port,
generation: self.gatewayStartGeneration)
if let err = result.error {
self.appendLog("[gateway] launchd auto-enable failed: \(err)\n")
}
return result.installed
}
private func enableLaunchAgentIfNeeded(
bundlePath: String,
port: Int,
generation expectedGeneration: UInt64? = nil) async -> LaunchAgentEnableResult
{
let generation = expectedGeneration ?? self.gatewayStartGeneration
await self.waitForPendingLaunchAgentDisable()
guard generation == self.gatewayStartGeneration else { return .skipped }
self.launchAgentEnableNextInvocationID &+= 1
let invocationID = self.launchAgentEnableNextInvocationID
let request = LaunchAgentEnableRequest(
bundlePath: bundlePath,
port: port,
generation: generation,
invocationIDs: [invocationID])
if let task = self.launchAgentEnableTask {
if var current = self.launchAgentEnableCurrentRequest,
current.hasSameConfiguration(as: request)
{
// The in-flight request already represents the newest configuration. Drop an
// older queued change so A -> B -> A cannot finish on B.
current.invocationIDs.append(invocationID)
self.launchAgentEnableCurrentRequest = current
if let pending = self.launchAgentEnablePendingRequest {
self.launchAgentEnableSupersededInvocationIDs.formUnion(pending.invocationIDs)
}
self.launchAgentEnablePendingRequest = nil
} else if var pending = self.launchAgentEnablePendingRequest,
pending.hasSameConfiguration(as: request)
{
pending.invocationIDs.append(invocationID)
self.launchAgentEnablePendingRequest = pending
} else {
if let pending = self.launchAgentEnablePendingRequest {
self.launchAgentEnableSupersededInvocationIDs.formUnion(pending.invocationIDs)
}
self.launchAgentEnablePendingRequest = request
}
let results = await task.value
return results[invocationID] ?? .skipped
}
self.launchAgentEnableSupersededInvocationIDs.removeAll(keepingCapacity: true)
self.launchAgentEnablePendingRequest = request
let task = Task { @MainActor in
await self.drainLaunchAgentEnableRequests()
}
self.launchAgentEnableTask = task
let results = await task.value
return results[invocationID] ?? .skipped
}
private func waitForPendingLaunchAgentDisable() async {
// A stop may already be uninstalling launchd. Wait until it finishes so a newer start's
// attach/install is ordered last; loop because another stop can supersede it while waiting.
while let disableTask = self.launchAgentDisableTask {
await disableTask.value
}
}
private func drainLaunchAgentEnableRequests()
async -> [UInt64: LaunchAgentEnableResult]
{
var results: [UInt64: LaunchAgentEnableResult] = [:]
while let request = self.launchAgentEnablePendingRequest {
self.launchAgentEnablePendingRequest = nil
self.launchAgentEnableSupersededInvocationIDs.subtract(request.invocationIDs)
self.launchAgentEnableCurrentRequest = request
let result = await self.performLaunchAgentEnable(request)
let completedRequest = self.launchAgentEnableCurrentRequest ?? request
for invocationID in completedRequest.invocationIDs {
results[invocationID] = result
}
self.launchAgentEnableCurrentRequest = nil
}
for invocationID in self.launchAgentEnableSupersededInvocationIDs {
guard results[invocationID] == nil else { continue }
results[invocationID] = .skipped
}
self.launchAgentEnableSupersededInvocationIDs.removeAll(keepingCapacity: true)
// Clear the task before returning. A later caller then starts a fresh drain instead of
// joining a completed task after the final pending-request check.
self.launchAgentEnableTask = nil
return results
}
private func performLaunchAgentEnable(_ request: LaunchAgentEnableRequest) async -> LaunchAgentEnableResult {
// App startup and onboarding can request persistence together. One drain owns all installs;
// a second forced install would kill the first Gateway during startup migrations.
let launchAgent = await GatewayLaunchAgentManager.loadedGatewayState(port: request.port)
// Pair one launchd snapshot with a current listener read. A PID that starts after the
// status read cannot look reusable, so the ownership guard preserves it instead of forcing
// an install; a reusable PID from this same snapshot receives its readiness cycle below.
let listener = await PortGuardian.shared.describe(port: request.port)
if let listener {
guard listener.pid == launchAgent.runningPID else {
// A healthy manually started Gateway may be attached without becoming app-owned.
// Persistence checks and retained repair markers must not replace it.
return .skipped
}
}
if let pid = launchAgent.reusablePID {
let failure = LaunchAgentReadinessFailure(port: request.port, pid: pid)
if self.launchAgentReadinessFailure != failure {
// A new launchd PID may still be running migrations. It must fail one complete
// readiness cycle before a later retry is allowed to replace it.
self.setLaunchAgentReadinessState(
candidate: LaunchAgentReadinessCandidate(
failure: failure,
generation: request.generation),
failure: nil)
return .skipped
}
self.appendLog(
"[gateway] launchd pid \(pid) failed readiness on port \(request.port); repairing\n")
self.logger.warning(
"gateway launchd pid=\(pid) failed readiness on port=\(request.port); repairing")
}
self.setLaunchAgentReadinessState(candidate: nil, failure: nil)
self.appendLog(
"[gateway] enabling launchd job (\(gatewayLaunchdLabel)) on port \(request.port)\n")
if let error = await GatewayLaunchAgentManager.set(
enabled: true,
bundlePath: request.bundlePath,
port: request.port)
{
return .failed(error)
}
// 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
return .installed()
}
private func resolveLaunchAgentReadinessFailure(
port: Int,
startingPID: Int32?) async -> LaunchAgentReadinessFailure?
{
guard let startingPID,
let pid = await GatewayLaunchAgentManager.reusableLoadedGatewayPID(port: port),
pid == startingPID
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)
}
private func setLaunchAgentReadinessState(
candidate: LaunchAgentReadinessCandidate?,
failure: LaunchAgentReadinessFailure?)
{
self.launchAgentReadinessCandidate = candidate
self.launchAgentReadinessFailure = failure
self.launchAgentReadinessRevision &+= 1
}
func startIfNeeded() {
guard self.desiredActive else { return }
// Do not spawn in remote mode (the gateway should run on the remote host).
guard !CommandResolver.connectionModeIsRemote() else {
self.status = .stopped
return
}
// Many surfaces can call `setActive(true)` in quick succession (startup, Canvas, health checks).
// Avoid spawning multiple concurrent "start" tasks that can thrash launchd and flap the port.
switch self.status {
case .starting, .running, .attachedExisting:
return
case .stopped, .failed:
break
}
self.status = .starting
self.gatewayStartGeneration &+= 1
let startGeneration = self.gatewayStartGeneration
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
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
}
func waitForStartupAttempt() async {
// Persistence/repair follows the complete attach-or-start decision. This prevents the
// automatic ensure path from replacing a PID while startup is accepting that same PID.
while let task = self.gatewayStartTask {
await task.value
}
}
func stop() {
self.gatewayStartGeneration &+= 1
let stopGeneration = self.gatewayStartGeneration
self.desiredActive = false
self.existingGatewayDetails = nil
self.lastFailureReason = nil
self.setLaunchAgentReadinessState(candidate: nil, failure: nil)
self.launchAgentInstallGeneration = 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
let enableTask = self.launchAgentEnableTask
self.status = .stopped
self.logger.info("gateway stop requested")
let bundlePath = Bundle.main.bundleURL.path
let priorDisableTask = self.launchAgentDisableTask
let disableTask = Task { @MainActor in
_ = await priorDisableTask?.value
_ = await enableTask?.value
if self.launchAgentDisableGeneration == stopGeneration {
_ = await GatewayLaunchAgentManager.set(
enabled: false,
bundlePath: bundlePath,
port: GatewayEnvironment.gatewayPort())
}
if self.launchAgentDisableGeneration == stopGeneration {
self.launchAgentDisableTask = nil
self.launchAgentDisableGeneration = nil
}
}
self.launchAgentDisableGeneration = stopGeneration
self.launchAgentDisableTask = disableTask
}
func clearLastFailure() {
self.lastFailureReason = nil
}
func refreshEnvironmentStatus(force: Bool = false) {
let now = Date()
if !force {
if self.environmentRefreshTask != nil { return }
if let last = self.lastEnvironmentRefresh,
now.timeIntervalSince(last) < self.environmentRefreshMinInterval
{
return
}
}
self.lastEnvironmentRefresh = now
self.environmentRefreshTask = Task { [weak self] in
let status = await Task.detached(priority: .utility) {
GatewayEnvironment.check()
}.value
await MainActor.run {
guard let self else { return }
self.environmentStatus = status
self.environmentRefreshTask = nil
}
}
}
func refreshLog() {
guard self.logRefreshTask == nil else { return }
let path = GatewayLaunchAgentManager.launchdGatewayLogPath()
let limit = self.logLimit
self.logRefreshTask = Task { [weak self] in
let log = await Task.detached(priority: .utility) {
Self.readGatewayLog(path: path, limit: limit)
}.value
await MainActor.run {
guard let self else { return }
if !log.isEmpty {
self.log = log
}
self.logRefreshTask = nil
}
}
}
// MARK: - Internals
private func isCurrentGatewayStart(_ generation: UInt64?) -> Bool {
guard let generation else { return true }
return self.desiredActive && self.gatewayStartGeneration == generation
}
private func attachExistingGatewayAfterPendingDisable(
port requestedPort: Int? = nil,
startGeneration: UInt64) async -> Bool
{
// A gateway that is still reachable during uninstall is not reusable. Let the stop finish
// before attachment so the new lifecycle cannot latch onto a process launchd then removes.
await self.waitForPendingLaunchAgentDisable()
guard self.isCurrentGatewayStart(startGeneration) else { return true }
return await self.attachExistingGatewayIfAvailable(
port: requestedPort,
startGeneration: startGeneration)
}
/// Attempt to connect to an already-running gateway on the configured port.
/// If successful, mark status as attached and skip spawning a new process.
private func attachExistingGatewayIfAvailable(
port requestedPort: Int? = nil,
startGeneration: UInt64? = nil) async -> Bool
{
let port = requestedPort ?? GatewayEnvironment.gatewayPort()
let instance = await PortGuardian.shared.describe(port: port)
guard self.isCurrentGatewayStart(startGeneration) else { return true }
let instanceText = instance.map { self.describe(instance: $0) }
let hasListener = instance != nil
let attemptAttach = {
try await self.probeGatewayHealth(timeoutMs: 2000)
}
for attempt in 0..<(hasListener ? 3 : 1) {
guard self.isCurrentGatewayStart(startGeneration) else { return true }
do {
let data = try await attemptAttach()
guard self.isCurrentGatewayStart(startGeneration) else { return true }
let attachedInstance = await PortGuardian.shared.describe(port: port)
guard self.isCurrentGatewayStart(startGeneration) else { return true }
let snap = decodeHealthSnapshot(from: data)
let attachedInstanceText = attachedInstance.map { self.describe(instance: $0) }
let details = self.describe(details: attachedInstanceText, port: port, snap: snap)
let endpointPIDChanged = Self.gatewayPIDChanged(
from: self.lastObservedGatewayPID,
to: attachedInstance?.pid) || Self.gatewayPIDChanged(
from: instance?.pid,
to: attachedInstance?.pid)
self.existingGatewayDetails = details
self.setLaunchAgentReadinessState(candidate: nil, failure: nil)
self.clearLastFailure()
self.status = .attachedExisting(details: details)
self.appendLog("[gateway] using existing instance: \(details)\n")
self.logger.info("gateway using existing instance details=\(details)")
self.refreshControlChannelIfNeeded(
reason: "attach existing",
force: endpointPIDChanged)
self.lastObservedGatewayPID = attachedInstance?.pid ?? self.lastObservedGatewayPID
self.refreshLog()
return true
} catch {
guard self.isCurrentGatewayStart(startGeneration) else { return true }
if attempt < 2, hasListener {
try? await Task.sleep(nanoseconds: 250_000_000)
continue
}
if hasListener {
let reason = self.describeAttachFailure(error, port: port, instance: instance)
self.existingGatewayDetails = instanceText
self.status = .failed(reason)
self.lastFailureReason = reason
self.appendLog("[gateway] existing listener on port \(port) but attach failed: \(reason)\n")
self.logger.warning("gateway attach failed reason=\(reason)")
return true
}
// No reachable gateway (and no listener) fall through to spawn.
self.existingGatewayDetails = nil
return false
}
}
self.existingGatewayDetails = nil
return false
}
private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String {
let instanceText = instance ?? "pid unknown"
if let snap {
let order = snap.channelOrder ?? Array(snap.channels.keys)
let linkId = order.first(where: { snap.channels[$0]?.linked == true })
?? order.first(where: { snap.channels[$0]?.linked != nil })
guard let linkId else {
return "port \(port), health probe succeeded, \(instanceText)"
}
let linked = snap.channels[linkId]?.linked ?? false
let authAge = snap.channels[linkId]?.authAgeMs.flatMap(msToAge) ?? "unknown age"
let label =
snap.channelLabels?[linkId] ??
linkId.capitalized
let linkText = linked ? "linked" : "not linked"
return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)"
}
return "port \(port), health probe succeeded, \(instanceText)"
}
private func describe(instance: PortGuardian.Descriptor) -> String {
let path = instance.executablePath ?? "path unknown"
return "pid \(instance.pid) \(instance.command) @ \(path)"
}
private func describeAttachFailure(_ error: Error, port: Int, instance: PortGuardian.Descriptor?) -> String {
let ns = error as NSError
let message = ns.localizedDescription.isEmpty ? "unknown error" : ns.localizedDescription
let lower = message.lowercased()
if self.isGatewayAuthFailure(error) {
return """
Gateway on port \(port) rejected auth. Set gateway.auth.token to match the running gateway \
(or clear it on the gateway) and retry.
"""
}
if lower.contains("protocol mismatch") {
return "Gateway on port \(port) is incompatible (protocol mismatch). Update the app/gateway."
}
if lower.contains("unexpected response") || lower.contains("invalid response") {
return "Port \(port) returned non-gateway data; another process is using it."
}
if let instance {
let instanceText = self.describe(instance: instance)
return "Gateway listener found on port \(port) (\(instanceText)) but health check failed: \(message)"
}
return "Gateway listener found on port \(port) but health check failed: \(message)"
}
private func isGatewayAuthFailure(_ error: Error) -> Bool {
if let urlError = error as? URLError, urlError.code == .dataNotAllowed {
return true
}
let ns = error as NSError
if ns.domain == "Gateway", ns.code == 1008 { return true }
let lower = ns.localizedDescription.lowercased()
return lower.contains("unauthorized") || lower.contains("auth")
}
}
extension GatewayProcessManager {
private func prepareLaunchdGatewayStart(startGeneration: UInt64) async -> LaunchAgentStartupContext? {
guard self.isCurrentGatewayStart(startGeneration) else { return nil }
self.existingGatewayDetails = nil
let resolution = await Task.detached(priority: .utility) {
GatewayEnvironment.resolveGatewayCommand()
}.value
guard self.isCurrentGatewayStart(startGeneration) else { return nil }
await MainActor.run { self.environmentStatus = resolution.status }
guard resolution.command != nil else {
await MainActor.run {
self.status = .failed(resolution.status.message)
}
self.logger.error("gateway command resolve failed: \(resolution.status.message)")
return nil
}
if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() {
let message = "Launchd disabled; start the Gateway manually or disable attach-only."
self.status = .failed(message)
self.lastFailureReason = "launchd disabled"
self.appendLog("[gateway] launchd disabled; skipping auto-start\n")
self.logger.info("gateway launchd enable skipped (disable marker set)")
return nil
}
let bundlePath = Bundle.main.bundleURL.path
let port = GatewayEnvironment.gatewayPort()
self.logger.info("gateway ensuring launchd port=\(port)")
let enableResult = await self.enableLaunchAgentIfNeeded(
bundlePath: bundlePath,
port: port,
generation: startGeneration)
guard self.isCurrentGatewayStart(startGeneration) else { return nil }
if let err = enableResult.error {
self.status = .failed(err)
self.lastFailureReason = err
self.logger.error("gateway launchd enable failed: \(err)")
return nil
}
let readinessPID = await GatewayLaunchAgentManager.reusableLoadedGatewayPID(port: port)
guard self.isCurrentGatewayStart(startGeneration) else { return nil }
return LaunchAgentStartupContext(
port: port,
enableResult: enableResult,
readinessPID: readinessPID,
readinessRevision: self.launchAgentReadinessRevision)
}
private func enableLaunchdGateway(startGeneration: UInt64) async {
guard let context = await self.prepareLaunchdGatewayStart(startGeneration: startGeneration) else {
return
}
// Best-effort: wait for the gateway to accept connections.
let deadline = Date().addingTimeInterval(6)
var latestRetryDisposition: GatewayProbeFailureDisposition?
while Date() < deadline {
guard !Task.isCancelled else { return }
guard self.isCurrentGatewayStart(startGeneration) else { return }
do {
let remainingMs = max(1, deadline.timeIntervalSinceNow * 1000)
_ = try await self.probeGatewayHealth(timeoutMs: min(1500, remainingMs))
guard !Task.isCancelled else { return }
let instance = await PortGuardian.shared.describe(port: context.port)
guard self.publishLaunchdGatewayReady(
instance: instance,
context: context,
startGeneration: startGeneration)
else { return }
return
} catch {
if Task.isCancelled || !self.isCurrentGatewayStart(startGeneration) {
return
}
switch self.probeFailureDisposition(error) {
case .fail:
await self.finishResponsiveGatewayProbeFailure(
error,
port: context.port,
startGeneration: startGeneration,
expectedCandidate: self.launchAgentReadinessCandidate,
expectedReadinessRevision: context.readinessRevision)
return
case .retryWithRepair:
latestRetryDisposition = .retryWithRepair
case .retryWithoutRepair:
// A responsive transient invalidates older connection-failure evidence.
latestRetryDisposition = .retryWithoutRepair
}
let retryDelay = min(0.4, max(0, deadline.timeIntervalSinceNow))
if retryDelay > 0 {
try? await Task.sleep(nanoseconds: UInt64(retryDelay * 1_000_000_000))
}
}
}
// 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 }
if latestRetryDisposition == .retryWithRepair {
await self.finishLaunchAgentReadinessFailure(
port: context.port,
startingPID: context.readinessPID,
startGeneration: startGeneration,
expectedReadinessRevision: context.readinessRevision)
} else {
self.finishGatewayReadinessDeadlineWithoutRepair(
startGeneration: startGeneration,
expectedReadinessRevision: context.readinessRevision)
}
}
private func publishLaunchdGatewayReady(
instance: PortGuardian.Descriptor?,
context: LaunchAgentStartupContext,
startGeneration: UInt64) -> Bool
{
guard !Task.isCancelled else { return false }
guard self.isCurrentGatewayStart(startGeneration) else { return false }
guard self.launchAgentReadinessRevision == context.readinessRevision else { return false }
let details = instance.map { "pid \($0.pid)" }
let endpointPIDChanged = if let readinessPID = context.readinessPID,
let observedPID = instance?.pid
{
readinessPID != observedPID
} else {
false
}
let previouslyObservedPIDChanged = Self.gatewayPIDChanged(
from: self.lastObservedGatewayPID,
to: instance?.pid)
let launchAgentReplaced = context.enableResult.installed ||
self.launchAgentInstallGeneration == startGeneration ||
endpointPIDChanged ||
previouslyObservedPIDChanged
self.setLaunchAgentReadinessState(candidate: nil, failure: nil)
self.clearLastFailure()
self.status = .running(details: details)
self.logger.info("gateway started details=\(details ?? "ok")")
self.refreshControlChannelIfNeeded(
reason: "gateway started",
force: launchAgentReplaced)
self.lastObservedGatewayPID = instance?.pid ?? self.lastObservedGatewayPID
if self.launchAgentInstallGeneration == startGeneration {
self.launchAgentInstallGeneration = nil
}
self.refreshLog()
return true
}
private func finishLaunchAgentReadinessFailure(
port: Int,
startingPID: Int32?,
startGeneration: UInt64,
expectedCandidate: LaunchAgentReadinessCandidate? = nil,
candidateMustMatch: Bool = false,
expectedReadinessRevision: UInt64? = nil) async
{
let failure = await self.resolveLaunchAgentReadinessFailure(
port: port,
startingPID: startingPID)
guard !Task.isCancelled else { return }
guard self.isCurrentGatewayStart(startGeneration) else { return }
if let expectedReadinessRevision,
self.launchAgentReadinessRevision != expectedReadinessRevision
{
return
}
if candidateMustMatch, self.launchAgentReadinessCandidate != expectedCandidate {
return
}
self.setLaunchAgentReadinessState(candidate: nil, failure: failure)
self.status = .failed("Gateway did not start in time")
self.lastFailureReason = "launchd start timeout"
self.logger.warning("gateway start timed out")
}
private func finishResponsiveGatewayProbeFailure(
_ error: Error,
port: Int,
startGeneration: UInt64,
expectedCandidate: LaunchAgentReadinessCandidate?,
expectedReadinessRevision: UInt64) async
{
let instance = await PortGuardian.shared.describe(port: port)
guard !Task.isCancelled else { return }
guard self.isCurrentGatewayStart(startGeneration) else { return }
guard self.launchAgentReadinessRevision == expectedReadinessRevision else { return }
guard self.launchAgentReadinessCandidate == expectedCandidate else { return }
let reason = self.describeAttachFailure(error, port: port, instance: instance)
self.setLaunchAgentReadinessState(candidate: nil, failure: nil)
self.status = .failed(reason)
self.lastFailureReason = reason
self.appendLog("[gateway] responsive health probe failed: \(reason)\n")
self.logger.warning("gateway responsive health probe failed reason=\(reason)")
}
private func finishGatewayReadinessDeadlineWithoutRepair(
startGeneration: UInt64,
expectedReadinessRevision: UInt64)
{
guard self.isCurrentGatewayStart(startGeneration) else { return }
guard self.launchAgentReadinessRevision == expectedReadinessRevision else { return }
// Transient RPC/cancellation responses do not prove the endpoint is unreachable. End the
// startup cleanly, but do not retain a PID that would authorize destructive repair.
self.setLaunchAgentReadinessState(
candidate: nil,
failure: self.launchAgentReadinessFailure)
self.status = .failed("Gateway did not become ready in time")
self.lastFailureReason = "gateway readiness deadline elapsed"
self.logger.warning("gateway readiness deadline elapsed without endpoint failure")
}
private func probeFailureDisposition(_ error: Error) -> GatewayProbeFailureDisposition {
if self.probeFailureIsCancellation(error) { return .retryWithoutRepair }
if let response = error as? GatewayResponseError,
response.code.uppercased() == "UNAVAILABLE"
{
return .retryWithoutRepair
}
if error is GatewayHealthProbeTimeout { return .retryWithRepair }
let nsError = error as NSError
guard nsError.domain == NSURLErrorDomain else { return .fail }
switch URLError.Code(rawValue: nsError.code) {
case .timedOut,
.cannotFindHost,
.cannotConnectToHost,
.networkConnectionLost,
.dnsLookupFailed,
.notConnectedToInternet,
.resourceUnavailable:
return .retryWithRepair
default:
return .fail
}
}
private func probeFailureIsCancellation(_ error: Error) -> Bool {
if error is CancellationError { return true }
let nsError = error as NSError
return nsError.domain == NSURLErrorDomain &&
nsError.code == URLError.cancelled.rawValue
}
private static func gatewayPIDChanged(from previousPID: Int32?, to observedPID: Int32?) -> Bool {
guard let previousPID, let observedPID else { return false }
return previousPID != observedPID
}
private func appendLog(_ chunk: String) {
self.log.append(chunk)
if self.log.count > self.logLimit {
self.log = String(self.log.suffix(self.logLimit))
}
}
private func refreshControlChannelIfNeeded(reason: String, force: Bool = false) {
#if DEBUG
self.testingControlChannelRefreshForces.append(force)
if self.testingSkipControlChannelRefresh {
return
}
#endif
if !force {
switch ControlChannel.shared.state {
case .connected, .connecting:
return
case .disconnected, .degraded:
break
}
}
self.appendLog("[gateway] refreshing control channel (\(reason))\n")
self.logger.debug("gateway control channel refresh reason=\(reason)")
Task { await ControlChannel.shared.configure() }
}
func waitForGatewayReady(
timeout: TimeInterval = 6,
launchAgentInstalled: Bool = false) async -> Bool
{
let startGeneration = self.gatewayStartGeneration
let readinessCandidate = self.launchAgentReadinessCandidate
let readinessFailure = self.launchAgentReadinessFailure
let readinessRevision = self.launchAgentReadinessRevision
let readinessPort = readinessCandidate?.failure.port
?? GatewayEnvironment.gatewayPort()
let deadline = Date().addingTimeInterval(timeout)
let endpointPIDBeforeProbe = self.lastObservedGatewayPID
var latestRetryDisposition: GatewayProbeFailureDisposition?
while Date() < deadline {
guard !Task.isCancelled else { return false }
guard self.isCurrentGatewayStart(startGeneration) else { return false }
do {
let remainingMs = max(1, deadline.timeIntervalSinceNow * 1000)
_ = try await self.probeGatewayHealth(timeoutMs: min(1500, remainingMs))
guard !Task.isCancelled else { return false }
let instance = await PortGuardian.shared.describe(port: readinessPort)
return self.publishGatewayReadinessSuccess(
instance: instance,
startGeneration: startGeneration,
readinessCandidate: readinessCandidate,
readinessRevision: readinessRevision,
launchAgentInstalled: launchAgentInstalled,
endpointPIDBeforeProbe: endpointPIDBeforeProbe)
} catch {
if Task.isCancelled || !self.isCurrentGatewayStart(startGeneration) {
return false
}
switch self.probeFailureDisposition(error) {
case .fail:
await self.finishResponsiveGatewayProbeFailure(
error,
port: readinessCandidate?.failure.port ?? GatewayEnvironment.gatewayPort(),
startGeneration: startGeneration,
expectedCandidate: readinessCandidate,
expectedReadinessRevision: readinessRevision)
return false
case .retryWithRepair:
latestRetryDisposition = .retryWithRepair
case .retryWithoutRepair:
// A responsive transient invalidates older connection-failure evidence.
latestRetryDisposition = .retryWithoutRepair
}
let retryDelay = min(0.3, max(0, deadline.timeIntervalSinceNow))
if retryDelay > 0 {
try? await Task.sleep(nanoseconds: UInt64(retryDelay * 1_000_000_000))
}
}
}
await self.finishGatewayReadinessTimeout(
startGeneration: startGeneration,
readinessCandidate: readinessCandidate,
readinessFailure: readinessFailure,
readinessRevision: readinessRevision,
latestRetryDisposition: latestRetryDisposition)
return false
}
private func publishGatewayReadinessSuccess(
instance: PortGuardian.Descriptor?,
startGeneration: UInt64,
readinessCandidate: LaunchAgentReadinessCandidate?,
readinessRevision: UInt64,
launchAgentInstalled: Bool,
endpointPIDBeforeProbe: Int32?) -> Bool
{
guard !Task.isCancelled else { return false }
guard self.desiredActive, self.gatewayStartGeneration == startGeneration else { return false }
guard self.launchAgentReadinessRevision == readinessRevision else { return false }
guard self.launchAgentReadinessCandidate == readinessCandidate else { return false }
let details = instance.map { "pid \($0.pid)" }
let launchAgentReplaced = launchAgentInstalled ||
self.launchAgentInstallGeneration == startGeneration
self.setLaunchAgentReadinessState(candidate: nil, failure: nil)
self.clearLastFailure()
if case .attachedExisting = self.status {
self.status = launchAgentReplaced
? .running(details: details)
: .attachedExisting(details: details)
} else {
self.status = .running(details: details)
}
let endpointPIDChanged = Self.gatewayPIDChanged(
from: endpointPIDBeforeProbe,
to: instance?.pid) || Self.gatewayPIDChanged(
from: readinessCandidate?.failure.pid,
to: instance?.pid)
// A replaced process can leave the old socket briefly marked connected. Routine audits
// retain the connected channel; only replacement evidence forces refresh.
self.refreshControlChannelIfNeeded(
reason: "gateway readiness recovered",
force: launchAgentReplaced || endpointPIDChanged)
self.lastObservedGatewayPID = instance?.pid ?? self.lastObservedGatewayPID
if self.launchAgentInstallGeneration == startGeneration {
self.launchAgentInstallGeneration = nil
}
self.refreshLog()
return true
}
private func finishGatewayReadinessTimeout(
startGeneration: UInt64,
readinessCandidate: LaunchAgentReadinessCandidate?,
readinessFailure: LaunchAgentReadinessFailure?,
readinessRevision: UInt64,
latestRetryDisposition: GatewayProbeFailureDisposition?) async
{
guard !Task.isCancelled else { return }
guard self.isCurrentGatewayStart(startGeneration) else { return }
guard self.launchAgentReadinessRevision == readinessRevision else { return }
guard self.launchAgentReadinessCandidate == readinessCandidate else { return }
self.appendLog("[gateway] readiness wait timed out\n")
guard latestRetryDisposition == .retryWithRepair else {
self.logger.warning("gateway readiness wait ended without endpoint failure evidence")
return
}
if let readinessCandidate,
readinessCandidate.generation == startGeneration
{
await self.finishLaunchAgentReadinessFailure(
port: readinessCandidate.failure.port,
startingPID: readinessCandidate.failure.pid,
startGeneration: startGeneration,
expectedCandidate: readinessCandidate,
candidateMustMatch: true,
expectedReadinessRevision: readinessRevision)
return
}
self.setLaunchAgentReadinessState(candidate: nil, failure: readinessFailure)
if case .failed = self.status {
// Startup or persistence already published a concrete launchd/configuration error.
// A follow-up reachability timeout must not replace that actionable diagnosis.
self.logger.warning("gateway readiness wait timed out; preserving existing failure")
} else {
self.status = .failed("Gateway did not start in time")
self.lastFailureReason = "gateway readiness timeout"
self.logger.warning("gateway readiness wait timed out")
}
}
private func probeGatewayHealth(timeoutMs: Double) async throws -> Data {
let connection = self.connection
// Startup owns recovery and its wall-clock deadline. A normal request can recursively
// start the Gateway and spend several 30-second connect retries before its RPC timer begins.
return try await AsyncTimeout.withTimeout(
seconds: max(0.001, timeoutMs / 1000),
onTimeout: { GatewayHealthProbeTimeout(timeoutMs: timeoutMs) },
operation: {
try await connection.request(
method: GatewayConnection.Method.health.rawValue,
params: nil,
timeoutMs: timeoutMs,
retryTransportFailures: false)
})
}
func clearLog() {
self.log = ""
try? FileManager().removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath())
self.logger.debug("gateway log cleared")
}
func setProjectRoot(path: String) {
CommandResolver.setProjectRoot(path)
}
func projectRootPath() -> String {
CommandResolver.projectRootPath()
}
private nonisolated static func readGatewayLog(path: String, limit: Int) -> String {
guard FileManager().fileExists(atPath: path) else { return "" }
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return "" }
let text = String(data: data, encoding: .utf8) ?? ""
if text.count <= limit { return text }
return String(text.suffix(limit))
}
}
#if DEBUG
extension GatewayProcessManager {
func setTestingConnection(_ connection: GatewayConnection?) {
self.testingConnection = connection
}
func setTestingSkipControlChannelRefresh(_ skip: Bool) {
self.testingSkipControlChannelRefresh = skip
}
func _testControlChannelRefreshForces() -> [Bool] {
self.testingControlChannelRefreshForces
}
func _testClearControlChannelRefreshForces() {
self.testingControlChannelRefreshForces.removeAll(keepingCapacity: true)
}
func _testClearLaunchAgentInstallEvidence() {
self.launchAgentInstallGeneration = nil
}
func _testSetLastObservedGatewayPID(_ pid: Int32?) {
self.lastObservedGatewayPID = pid
}
func _testProbeFailureMayNeedLaunchAgentRepair(_ code: URLError.Code) -> Bool {
if case .retryWithRepair = self.probeFailureDisposition(URLError(code)) {
return true
}
return false
}
func _testGatewayResponseRetriesWithoutRepair(_ code: String) -> Bool {
let error = GatewayResponseError(
method: "health",
code: code,
message: "test",
details: nil)
if case .retryWithoutRepair = self.probeFailureDisposition(error) {
return true
}
return false
}
func setTestingDesiredActive(_ active: Bool) {
self.desiredActive = active
}
func setTestingLastFailureReason(_ reason: String?) {
self.lastFailureReason = reason
}
func setTestingStatus(_ status: Status) {
self.status = status
}
func _testAttachExistingGatewayIfAvailable(port: Int) async -> Bool {
await self.attachExistingGatewayIfAvailable(port: port)
}
func _testAttachExistingGatewayAfterPendingDisable(port: Int) async -> Bool {
await self.attachExistingGatewayAfterPendingDisable(
port: port,
startGeneration: self.gatewayStartGeneration)
}
func _testEnableLaunchAgentIfNeeded(bundlePath: String, port: Int) async -> String? {
await self.enableLaunchAgentIfNeeded(bundlePath: bundlePath, port: port).error
}
func _testEnableLaunchAgentIfNeededInstalled(bundlePath: String, port: Int) async -> Bool {
await self.enableLaunchAgentIfNeeded(bundlePath: bundlePath, port: port).installed
}
func _testRecordLaunchAgentReadinessFailure(port: Int, startingPID: Int32?) async {
let failure = await self.resolveLaunchAgentReadinessFailure(
port: port,
startingPID: startingPID)
self.setLaunchAgentReadinessState(
candidate: self.launchAgentReadinessCandidate,
failure: failure)
}
func _testFinishLaunchAgentReadinessFailure(port: Int, startingPID: Int32?) async {
let startGeneration = self.gatewayStartGeneration
await self.finishLaunchAgentReadinessFailure(
port: port,
startingPID: startingPID,
startGeneration: startGeneration)
}
func _testClearLaunchAgentReadinessFailure() {
self.setLaunchAgentReadinessState(candidate: nil, failure: nil)
}
func _testSetLaunchAgentReadinessFailure(port: Int, pid: Int32) {
self.setLaunchAgentReadinessState(
candidate: self.launchAgentReadinessCandidate,
failure: LaunchAgentReadinessFailure(port: port, pid: pid))
}
func _testSetLaunchAgentReadinessCandidate(port: Int, pid: Int32) {
self.setLaunchAgentReadinessState(
candidate: LaunchAgentReadinessCandidate(
failure: LaunchAgentReadinessFailure(port: port, pid: pid),
generation: self.gatewayStartGeneration),
failure: self.launchAgentReadinessFailure)
}
func _testHasLaunchAgentReadinessFailure() -> Bool {
self.launchAgentReadinessFailure != nil
}
func _testHasLaunchAgentReadinessCandidate() -> Bool {
self.launchAgentReadinessCandidate != nil
}
func _testLaunchAgentReadinessCandidatePID() -> Int32? {
self.launchAgentReadinessCandidate?.failure.pid
}
func _testBeginGatewayStartGeneration() {
self.desiredActive = true
self.gatewayStartGeneration &+= 1
}
func _testPendingLaunchAgentPort() -> Int? {
self.launchAgentEnablePendingRequest?.port
}
}
#endif