fix(macos): first-run gateway startup survives state migrations (#121012)

* fix(macos): tolerate first-run state migrations during gateway start

* fix(macos): tolerate loaded-host version probes
This commit is contained in:
Peter Steinberger
2026-08-09 03:11:38 -07:00
committed by GitHub
parent c092900e3c
commit ab5e10fd17
10 changed files with 63 additions and 19 deletions

View File

@@ -573,7 +573,8 @@ enum CLIInstaller {
paused: Bool = AppStateStore.shared.isPaused,
start: @MainActor () -> Void = { GatewayProcessManager.shared.setActive(true) },
waitUntilReady: @MainActor () async -> Bool = {
await GatewayProcessManager.shared.waitForGatewayReady(timeout: 12)
await GatewayProcessManager.shared.waitForGatewayReady(
timeout: GatewayLaunchAgentManager.startupMigrationTolerance)
}) async -> LocalGatewayActivation
{
guard mode == .local, !paused else { return .deferred }

View File

@@ -3,6 +3,8 @@ import Foundation
enum CommandResolver {
private static let projectRootDefaultsKey = "openclaw.gatewayProjectRootPath"
private static let helperName = "openclaw"
/// Version probes may queue under machine load; keep command resolution tolerant but bounded.
static let versionProbeTimeout: TimeInterval = 10
static func gatewayEntrypoint(in root: URL) -> String? {
let distEntry = root.appendingPathComponent("dist/index.js").path

View File

@@ -332,7 +332,7 @@ enum GatewayEnvironment {
path: binary,
arguments: ["--version"],
environment: ["PATH": searchPaths.joined(separator: ":")],
timeout: 2)
timeout: CommandResolver.versionProbeTimeout)
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
if elapsedMs > 500 {
self.logger.warning(

View File

@@ -8,6 +8,9 @@ enum GatewayLaunchAgentManager {
private static let logger = Logger(subsystem: "ai.openclaw", category: "gateway.launchd")
private static let disableLaunchAgentMarker = ".openclaw/disable-launchagent"
/// A first-run daemon command may wait behind state integrity checks and the shared startup-
/// migration lease. Keep the app from killing healthy migration work before it can finish.
static let startupMigrationTolerance: TimeInterval = 120
private static var disableLaunchAgentMarkerURL: URL {
#if DEBUG
@@ -130,7 +133,7 @@ enum GatewayLaunchAgentManager {
self.logger.info("launchd restart skipped (disable marker set)")
return nil
}
return await self.runDaemonCommand(["restart"], timeout: 20)
return await self.runDaemonCommand(["restart"])
}
static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? {
@@ -234,7 +237,7 @@ extension GatewayLaunchAgentManager {
private static func runDaemonCommand(
_ args: [String],
timeout: Double = 15,
timeout: Double = Self.startupMigrationTolerance,
quiet: Bool = false) async -> String?
{
let result = await self.runDaemonCommandResult(args, timeout: timeout, quiet: quiet)

View File

@@ -698,14 +698,13 @@ extension GatewayProcessManager {
context: LaunchAgentStartupContext,
startGeneration: UInt64,
readinessWindow: TimeInterval = 6,
// A fresh install gets ten six-second probe windows for Local Network authorization.
firstInstallReadinessGraceWindows: Int = 9) async
// Fresh installs keep probing through the same first-run migration budget as the CLI.
firstInstallReadinessBudget: TimeInterval = GatewayLaunchAgentManager.startupMigrationTolerance) async
{
let startedAt = Date()
var deadline = startedAt.addingTimeInterval(readinessWindow)
let graceWindowCount = max(0, firstInstallReadinessGraceWindows)
let finalProbeDeadline = startedAt.addingTimeInterval(
readinessWindow * (Double(graceWindowCount) + 1))
max(readinessWindow, firstInstallReadinessBudget))
var latestRetryDisposition: GatewayProbeFailureDisposition?
var readinessPID = context.readinessPID
var freshInstallGraceAuthorized = false
@@ -983,7 +982,8 @@ extension GatewayProcessManager {
launchAgentInstalled: Bool = false) async -> Bool
{
let startGeneration = self.gatewayStartGeneration
if let result = await self.observeCurrentGatewayStart(generation: startGeneration) { return result }
if await self.observeCurrentGatewayStart(generation: startGeneration) == true { return true }
guard !Task.isCancelled, self.isCurrentGatewayStart(startGeneration) else { return false }
let readinessCandidate = self.launchAgentReadinessCandidate
let readinessFailure = self.launchAgentReadinessFailure
let readinessRevision = self.launchAgentReadinessRevision
@@ -1337,7 +1337,7 @@ extension GatewayProcessManager {
port: Int,
pid: Int32,
readinessWindow: TimeInterval,
firstInstallReadinessGraceWindows: Int)
firstInstallReadinessBudget: TimeInterval)
{
self.desiredActive = true
self.status = .starting
@@ -1354,7 +1354,7 @@ extension GatewayProcessManager {
readinessRevision: readinessRevision),
startGeneration: generation,
readinessWindow: readinessWindow,
firstInstallReadinessGraceWindows: firstInstallReadinessGraceWindows)
firstInstallReadinessBudget: firstInstallReadinessBudget)
}
}
}

View File

@@ -147,7 +147,7 @@ enum RuntimeLocator {
path: binary,
arguments: ["--version"],
environment: ["PATH": pathEnv],
timeout: 2)
timeout: CommandResolver.versionProbeTimeout)
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
if elapsedMs > 500 {
self.logger.warning(

View File

@@ -72,6 +72,22 @@ struct GatewayEnvironmentTests {
#expect(version == "2026.7.29")
}
@Test func `gateway version probe tolerates loaded host delay`() async throws {
let root = try makeTempDirForTests()
defer { try? FileManager.default.removeItem(at: root) }
let gateway = root.appendingPathComponent("openclaw")
try "#!/bin/sh\nsleep 2.1\necho OpenClaw 2026.7.30\n"
.write(to: gateway, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: gateway.path)
let version = await GatewayEnvironment.installedGatewayVersion(
gatewayBin: gateway.path,
projectRoot: root,
searchPaths: [root.path, "/usr/bin", "/bin"])
#expect(version == "2026.7.30")
}
@Test func `gateway launch resolution scans preferred paths once`() async throws {
let root = try makeTempDirForTests()
defer { try? FileManager.default.removeItem(at: root) }

View File

@@ -4,6 +4,10 @@ import Testing
@Suite(.serialized)
struct GatewayLaunchAgentManagerTests {
@Test func `daemon commands tolerate first run state migrations`() {
#expect(GatewayLaunchAgentManager.startupMigrationTolerance >= 120)
}
@Test func `reads Gateway service ownership command directly from launchd`() throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("openclaw-gateway-\(UUID().uuidString).plist")

View File

@@ -980,11 +980,13 @@ struct GatewayProcessManagerTests {
}
}
@Test func `readiness waiter observes the current owner past its timeout`() async throws {
@Test func `readiness waiter rechecks after current owner fails past its timeout`() async throws {
let port = 19114
let url = try #require(URL(string: "ws://example.invalid"))
let (session, connection, manager) = self.makeGatewayReadinessFixture(url: url) {
self.gatewayTask(healthSucceedsAfter: 1)
self.gatewayTask(
healthSucceedsAfter: 0,
stallsFirstHealthResponse: true)
}
defer { manager.setTestingConnection(nil) }
@@ -1007,7 +1009,7 @@ struct GatewayProcessManagerTests {
port: port,
pid: 4242,
readinessWindow: 0.5,
firstInstallReadinessGraceWindows: 1)
firstInstallReadinessBudget: 0.5)
let readiness = Task { @MainActor in
await manager.waitForGatewayReady(timeout: 0.01)
}
@@ -1019,6 +1021,7 @@ struct GatewayProcessManagerTests {
#expect(!manager._testHasLaunchAgentReadinessFailure())
#expect(await readiness.value)
#expect(manager.status == .running(details: "pid 4242"))
#expect((session.latestTask()?.snapshotSendCount() ?? 0) > 1)
await connection.shutdown()
await PortGuardian.shared.setTestingDescriptor(nil, forPort: port)
@@ -1046,7 +1049,7 @@ struct GatewayProcessManagerTests {
port: port,
pid: 4242,
readinessWindow: 0.5,
firstInstallReadinessGraceWindows: 1)
firstInstallReadinessBudget: 1)
let readiness = Task { @MainActor in
await manager.waitForGatewayReady(timeout: 0.01)
}
@@ -1095,7 +1098,7 @@ struct GatewayProcessManagerTests {
port: port,
pid: 4242,
readinessWindow: 0.05,
firstInstallReadinessGraceWindows: 2)
firstInstallReadinessBudget: 0.15)
await manager.waitForStartupAttempt()
#expect(GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
@@ -1138,7 +1141,7 @@ struct GatewayProcessManagerTests {
port: port,
pid: 4242,
readinessWindow: 0.01,
firstInstallReadinessGraceWindows: 1)
firstInstallReadinessBudget: 0.02)
await manager.waitForStartupAttempt()
#expect(manager.status == .failed("Gateway did not start in time"))
@@ -1180,7 +1183,7 @@ struct GatewayProcessManagerTests {
port: port,
pid: 4242,
readinessWindow: 0.05,
firstInstallReadinessGraceWindows: 1)
firstInstallReadinessBudget: 0.1)
await manager.waitForStartupAttempt()
guard case .failed("Gateway did not start in time") = manager.status else {
Issue.record("fresh launchd readiness did not fail within its bounded grace")

View File

@@ -28,6 +28,21 @@ struct RuntimeLocatorTests {
#expect(res.version == RuntimeVersion(major: 22, minor: 22, patch: 3))
}
@Test func `runtime version probe tolerates loaded host delay`() async throws {
let script = """
#!/bin/sh
/bin/sleep 2.1
echo v22.22.3
"""
let node = try self.makeTempExecutable(contents: script)
let result = await RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path])
guard case let .success(resolution) = result else {
Issue.record("Expected delayed version probe to succeed, got \(result)")
return
}
#expect(resolution.version == RuntimeVersion(major: 22, minor: 22, patch: 3))
}
@Test func `resolve fails on boundary below minimum`() async throws {
let script = """
#!/bin/sh