fix(macos): make launchd enable idempotent
This commit is contained in:
@@ -59,19 +59,22 @@ enum GatewayLaunchAgentManager {
|
|||||||
return "Embedded gateway missing in bundle; rebuild via scripts/package-mac-app.sh"
|
return "Embedded gateway missing in bundle; rebuild via scripts/package-mac-app.sh"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if service is already running - if so, skip bootout to avoid killing it
|
let desiredBind = self.preferredGatewayBind() ?? "loopback"
|
||||||
let alreadyRunning = await self.status()
|
self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)")
|
||||||
if alreadyRunning {
|
self.writePlist(bundlePath: bundlePath, port: port)
|
||||||
self.logger.info("launchd service already running, skipping bootout")
|
|
||||||
// Still update plist in case config changed, but don't restart
|
// If launchd already loaded the job (common on login), avoid `bootout` unless we must
|
||||||
self.writePlist(bundlePath: bundlePath, port: port)
|
// change the config. `bootout` can kill a just-started gateway and cause attach loops.
|
||||||
// Ensure service is marked as enabled for auto-start on login
|
if let snapshot = await self.gatewayJobSnapshot(),
|
||||||
_ = await self.runLaunchctl(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
snapshot.matches(port: port, bind: desiredBind)
|
||||||
|
{
|
||||||
|
self.logger.info("launchd job already loaded with desired config; skipping bootout")
|
||||||
|
await self.ensureEnabled()
|
||||||
|
_ = await self.runLaunchctl(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
self.logger.info("launchd enable requested port=\(port)")
|
await self.ensureEnabled()
|
||||||
self.writePlist(bundlePath: bundlePath, port: port)
|
|
||||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||||
let bootstrap = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
|
let bootstrap = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
|
||||||
if bootstrap.status != 0 {
|
if bootstrap.status != 0 {
|
||||||
@@ -81,11 +84,7 @@ enum GatewayLaunchAgentManager {
|
|||||||
? "Failed to bootstrap gateway launchd job"
|
? "Failed to bootstrap gateway launchd job"
|
||||||
: bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
: bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
}
|
}
|
||||||
// Ensure service is marked as enabled for auto-start on login
|
await self.ensureEnabled()
|
||||||
_ = await self.runLaunchctl(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
|
||||||
// Note: removed redundant `kickstart -k` that caused race condition.
|
|
||||||
// bootstrap already starts the job; kickstart -k would kill it immediately
|
|
||||||
// and with KeepAlive=true, cause a restart loop with port conflicts.
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +226,84 @@ enum GatewayLaunchAgentManager {
|
|||||||
let output: String
|
let output: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct LaunchdJobSnapshot: Equatable {
|
||||||
|
let pid: Int?
|
||||||
|
let port: Int?
|
||||||
|
let bind: String?
|
||||||
|
|
||||||
|
func matches(port: Int, bind: String) -> Bool {
|
||||||
|
guard self.port == port else { return false }
|
||||||
|
if let bindValue = self.bind {
|
||||||
|
return bindValue == bind
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parseLaunchctlPrintSnapshot(_ output: String) -> LaunchdJobSnapshot {
|
||||||
|
let pid = self.extractIntValue(output: output, key: "pid")
|
||||||
|
let port = self.extractFlagIntValue(output: output, flag: "--port")
|
||||||
|
let bind = self.extractFlagStringValue(output: output, flag: "--bind")?.lowercased()
|
||||||
|
return LaunchdJobSnapshot(pid: pid, port: port, bind: bind)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func gatewayJobSnapshot() async -> LaunchdJobSnapshot? {
|
||||||
|
let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||||
|
guard result.status == 0 else { return nil }
|
||||||
|
return self.parseLaunchctlPrintSnapshot(result.output)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func ensureEnabled() async {
|
||||||
|
let result = await self.runLaunchctl(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||||
|
guard result.status != 0 else { return }
|
||||||
|
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if msg.isEmpty {
|
||||||
|
self.logger.warning("launchd enable failed")
|
||||||
|
} else {
|
||||||
|
self.logger.warning("launchd enable failed: \(msg)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func extractIntValue(output: String, key: String) -> Int? {
|
||||||
|
// launchctl print commonly emits `pid = 123`
|
||||||
|
guard let range = output.range(of: "\(key) =") else { return nil }
|
||||||
|
var idx = range.upperBound
|
||||||
|
while idx < output.endIndex, output[idx].isWhitespace { idx = output.index(after: idx) }
|
||||||
|
var end = idx
|
||||||
|
while end < output.endIndex, output[end].isNumber { end = output.index(after: end) }
|
||||||
|
guard end > idx else { return nil }
|
||||||
|
return Int(output[idx..<end])
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func extractFlagIntValue(output: String, flag: String) -> Int? {
|
||||||
|
guard let raw = self.extractFlagStringValue(output: output, flag: flag) else { return nil }
|
||||||
|
return Int(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func extractFlagStringValue(output: String, flag: String) -> String? {
|
||||||
|
guard let range = output.range(of: flag) else { return nil }
|
||||||
|
var idx = range.upperBound
|
||||||
|
while idx < output.endIndex {
|
||||||
|
let ch = output[idx]
|
||||||
|
if ch.isWhitespace || ch == "," || ch == "(" || ch == ")" || ch == "=" || ch == "\"" || ch == "'" {
|
||||||
|
idx = output.index(after: idx)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
guard idx < output.endIndex else { return nil }
|
||||||
|
var end = idx
|
||||||
|
while end < output.endIndex {
|
||||||
|
let ch = output[end]
|
||||||
|
if ch.isWhitespace || ch == "," || ch == "(" || ch == ")" || ch == "\"" || ch == "'" || ch == "\n" || ch == "\r" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
end = output.index(after: end)
|
||||||
|
}
|
||||||
|
let token = output[idx..<end].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return token.isEmpty ? nil : String(token)
|
||||||
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private static func runLaunchctl(_ args: [String]) async -> LaunchctlResult {
|
private static func runLaunchctl(_ args: [String]) async -> LaunchctlResult {
|
||||||
await Task.detached(priority: .utility) { () -> LaunchctlResult in
|
await Task.detached(priority: .utility) { () -> LaunchctlResult in
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import Clawdbot
|
||||||
|
|
||||||
|
@Suite struct GatewayLaunchAgentManagerTests {
|
||||||
|
@Test func parseLaunchctlPrintSnapshotParsesQuotedArgs() {
|
||||||
|
let output = """
|
||||||
|
service = com.clawdbot.gateway
|
||||||
|
program arguments = (
|
||||||
|
"/Applications/Clawdbot.app/Contents/Resources/Relay/clawdbot",
|
||||||
|
"gateway-daemon",
|
||||||
|
"--port",
|
||||||
|
"18789",
|
||||||
|
"--bind",
|
||||||
|
"loopback"
|
||||||
|
)
|
||||||
|
pid = 123
|
||||||
|
"""
|
||||||
|
let snapshot = GatewayLaunchAgentManager.parseLaunchctlPrintSnapshot(output)
|
||||||
|
#expect(snapshot.pid == 123)
|
||||||
|
#expect(snapshot.port == 18789)
|
||||||
|
#expect(snapshot.bind == "loopback")
|
||||||
|
#expect(snapshot.matches(port: 18789, bind: "loopback"))
|
||||||
|
#expect(snapshot.matches(port: 18789, bind: "tailnet") == false)
|
||||||
|
#expect(snapshot.matches(port: 19999, bind: "loopback") == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func parseLaunchctlPrintSnapshotParsesUnquotedArgs() {
|
||||||
|
let output = """
|
||||||
|
argv[] = { /usr/local/bin/clawdbot, gateway-daemon, --port, 19999, --bind, tailnet }
|
||||||
|
pid = 0
|
||||||
|
"""
|
||||||
|
let snapshot = GatewayLaunchAgentManager.parseLaunchctlPrintSnapshot(output)
|
||||||
|
#expect(snapshot.pid == 0)
|
||||||
|
#expect(snapshot.port == 19999)
|
||||||
|
#expect(snapshot.bind == "tailnet")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func parseLaunchctlPrintSnapshotAllowsMissingBind() {
|
||||||
|
let output = """
|
||||||
|
program arguments = ( "clawdbot", "gateway-daemon", "--port", "18789" )
|
||||||
|
pid = 456
|
||||||
|
"""
|
||||||
|
let snapshot = GatewayLaunchAgentManager.parseLaunchctlPrintSnapshot(output)
|
||||||
|
#expect(snapshot.port == 18789)
|
||||||
|
#expect(snapshot.bind == nil)
|
||||||
|
#expect(snapshot.matches(port: 18789, bind: "loopback"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user