mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-13 17:07:40 +00:00
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:
committed by
GitHub
parent
c092900e3c
commit
ab5e10fd17
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user