1298 lines
48 KiB
Swift
1298 lines
48 KiB
Swift
#if canImport(Darwin)
|
|
import Darwin
|
|
#elseif canImport(Glibc)
|
|
import Glibc
|
|
#elseif canImport(Musl)
|
|
import Musl
|
|
#endif
|
|
import Foundation
|
|
|
|
// MARK: - Antigravity CLI Process Abstractions
|
|
|
|
protocol AntigravityCLIProcessHandle: AnyObject, Sendable {
|
|
var pid: pid_t { get }
|
|
var isRunning: Bool { get }
|
|
var processGroup: pid_t? { get }
|
|
|
|
func assignProcessGroup() -> pid_t?
|
|
func sendExit() throws
|
|
func closePTY()
|
|
func terminateRoot()
|
|
func killRoot()
|
|
func descendantPIDs() -> [pid_t]
|
|
func terminateTree(signal: Int32, knownDescendants: [pid_t])
|
|
func killDescendants(_ descendants: [pid_t])
|
|
func drainOutput() -> Data
|
|
}
|
|
|
|
protocol AntigravityCLIProcessLaunching: Sendable {
|
|
func launch(binary: String) throws -> any AntigravityCLIProcessHandle
|
|
}
|
|
|
|
enum AntigravityCLIAuthenticationPrompt {
|
|
static let evidence = Data("Select login method:".utf8)
|
|
private static let promptPattern = #"select\s+login\s+method\s*:?"#
|
|
|
|
static func contains(_ output: Data) -> Bool {
|
|
// `agy` briefly prints "You are currently not signed in" before it
|
|
// auto-refreshes an existing login. The actual blocking state is the
|
|
// interactive login-method prompt. The exact prompt is a CLI-owned TUI
|
|
// string, so keep matching tolerant to casing and whitespace changes.
|
|
if output.range(of: self.evidence) != nil {
|
|
return true
|
|
}
|
|
let asciiBytes = output.map { $0 < 0x80 ? $0 : 0x20 }
|
|
let text = String(bytes: asciiBytes, encoding: .utf8) ?? ""
|
|
return text.range(
|
|
of: self.promptPattern,
|
|
options: [.regularExpression, .caseInsensitive]) != nil
|
|
}
|
|
}
|
|
|
|
struct AntigravityCLIProcessIdentity: Equatable {
|
|
let executablePath: String
|
|
let startEpoch: TimeInterval
|
|
}
|
|
|
|
protocol AntigravityCLIProcessIdentityProviding: Sendable {
|
|
func identity(for pid: pid_t) -> AntigravityCLIProcessIdentity?
|
|
}
|
|
|
|
struct AntigravityCLISessionRecord: Codable, Equatable {
|
|
let pid: pid_t
|
|
let requestedBinaryPath: String
|
|
let executablePath: String
|
|
let startEpoch: TimeInterval
|
|
let processGroup: pid_t?
|
|
let ownerPID: pid_t?
|
|
let ownerExecutablePath: String?
|
|
let ownerStartEpoch: TimeInterval?
|
|
|
|
init(
|
|
pid: pid_t,
|
|
requestedBinaryPath: String,
|
|
executablePath: String,
|
|
startEpoch: TimeInterval,
|
|
processGroup: pid_t?,
|
|
ownerPID: pid_t? = nil,
|
|
ownerExecutablePath: String? = nil,
|
|
ownerStartEpoch: TimeInterval? = nil)
|
|
{
|
|
self.pid = pid
|
|
self.requestedBinaryPath = requestedBinaryPath
|
|
self.executablePath = executablePath
|
|
self.startEpoch = startEpoch
|
|
self.processGroup = processGroup
|
|
self.ownerPID = ownerPID
|
|
self.ownerExecutablePath = ownerExecutablePath
|
|
self.ownerStartEpoch = ownerStartEpoch
|
|
}
|
|
}
|
|
|
|
protocol AntigravityCLISessionRecordStoring: Sendable {
|
|
func load() throws -> [AntigravityCLISessionRecord]
|
|
func save(_ record: AntigravityCLISessionRecord) throws
|
|
func remove(_ record: AntigravityCLISessionRecord) throws
|
|
}
|
|
|
|
protocol AntigravityCLISessionLaunchLocking: Sendable {
|
|
func withLock<T>(_ operation: () throws -> T) throws -> T
|
|
}
|
|
|
|
// MARK: - AntigravityCLISession
|
|
|
|
/// Manages a bounded background ``agy`` process whose embedded localhost server
|
|
/// provides the same ``GetUserStatus`` endpoint as the desktop Antigravity app's
|
|
/// ``language_server``. The CLI is kept alive in a PTY so its daemon stays bound
|
|
/// to a local port - this lets CodexBar read Claude + Gemini quotas even when
|
|
/// the desktop Antigravity app is closed.
|
|
///
|
|
/// The session intentionally does not scrape TUI output. It only launches and
|
|
/// keeps the process reachable for HTTPS probing, drains discarded PTY output so
|
|
/// the CLI cannot block on a full terminal buffer, and bounds the warm lifetime
|
|
/// with an idle timer so CodexBar does not run an IDE backend forever.
|
|
actor AntigravityCLISession {
|
|
static let shared = AntigravityCLISession()
|
|
private static let log = CodexBarLog.logger(LogCategories.antigravity)
|
|
|
|
enum ResetCause: Int {
|
|
case deferred
|
|
case oneShotCLI
|
|
case unhealthy
|
|
case authentication
|
|
|
|
var message: String {
|
|
switch self {
|
|
case .deferred: "deferred reset"
|
|
case .oneShotCLI: "one-shot CLI fetch"
|
|
case .unhealthy: "unhealthy CLI HTTPS session"
|
|
case .authentication: "authentication required"
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct LaunchOutcome {
|
|
let pid: pid_t
|
|
let rejectedProcess: (any AntigravityCLIProcessHandle)?
|
|
let rejectionMessage: String?
|
|
let holdsLaunchReservation: Bool
|
|
}
|
|
|
|
struct Dependencies {
|
|
var launcher: any AntigravityCLIProcessLaunching
|
|
var identityProvider: any AntigravityCLIProcessIdentityProviding
|
|
var recordStore: any AntigravityCLISessionRecordStoring
|
|
var launchLock: any AntigravityCLISessionLaunchLocking
|
|
var beginAppShutdownTrackedLaunch: @Sendable () -> Bool
|
|
var endAppShutdownTrackedLaunch: @Sendable () -> Void
|
|
var registerForAppShutdown: @Sendable (pid_t, String) -> Bool
|
|
var updateAppShutdownProcessGroup: @Sendable (pid_t, pid_t?) -> Void
|
|
var unregisterForAppShutdown: @Sendable (pid_t) -> Void
|
|
var descendantPIDs: @Sendable (pid_t) -> [pid_t]
|
|
var terminateProcessTree: @Sendable (pid_t, pid_t?, Int32, [pid_t]) -> Void
|
|
var currentProcessID: @Sendable () -> pid_t
|
|
var now: @Sendable () -> Date
|
|
var sleep: @Sendable (UInt64) async throws -> Void
|
|
var idleWindow: TimeInterval
|
|
var failureRelaunchThreshold: Int
|
|
var terminationGracePeriod: TimeInterval
|
|
|
|
static func live() -> Self {
|
|
Self(
|
|
launcher: AntigravityPTYProcessLauncher(),
|
|
identityProvider: AntigravityProcessIdentityProvider(),
|
|
recordStore: AntigravityFileCLISessionRecordStore(),
|
|
launchLock: AntigravityFileCLISessionLaunchLock(),
|
|
beginAppShutdownTrackedLaunch: {
|
|
TTYCommandRunner.beginActiveProcessLaunchForAppShutdown()
|
|
},
|
|
endAppShutdownTrackedLaunch: {
|
|
TTYCommandRunner.endActiveProcessLaunchForAppShutdown()
|
|
},
|
|
registerForAppShutdown: { pid, binary in
|
|
TTYCommandRunner.registerActiveProcessForAppShutdown(pid: pid, binary: binary)
|
|
},
|
|
updateAppShutdownProcessGroup: { pid, group in
|
|
TTYCommandRunner.updateActiveProcessGroupForAppShutdown(pid: pid, processGroup: group)
|
|
},
|
|
unregisterForAppShutdown: { pid in
|
|
TTYCommandRunner.unregisterActiveProcessForAppShutdown(pid: pid)
|
|
},
|
|
descendantPIDs: { pid in
|
|
TTYProcessTreeTerminator.descendantPIDs(of: pid)
|
|
},
|
|
terminateProcessTree: { pid, group, signal, knownDescendants in
|
|
TTYProcessTreeTerminator.terminateProcessTree(
|
|
rootPID: pid,
|
|
processGroup: group,
|
|
signal: signal,
|
|
knownDescendants: knownDescendants)
|
|
},
|
|
currentProcessID: getpid,
|
|
now: Date.init,
|
|
sleep: { nanoseconds in
|
|
try await Task.sleep(nanoseconds: nanoseconds)
|
|
},
|
|
idleWindow: 180,
|
|
failureRelaunchThreshold: 2,
|
|
terminationGracePeriod: 1)
|
|
}
|
|
}
|
|
|
|
// MARK: State
|
|
|
|
private let dependencies: Dependencies
|
|
private var process: (any AntigravityCLIProcessHandle)?
|
|
private var binaryPath: String?
|
|
private var sessionIdleWindow: TimeInterval
|
|
private var activeProbeCount = 0
|
|
private var activeSessionProbeCount = 0
|
|
private var resetRequestedWhenIdle = false
|
|
private var hardResetRequestedWhenIdle = false
|
|
private var pendingResetCause: ResetCause?
|
|
private var idleTask: Task<Void, Never>?
|
|
private var sessionGeneration: UInt64 = 0
|
|
private var consecutiveProbeFailures = 0
|
|
private var persistedProcessIdentity: AntigravityCLIProcessIdentity?
|
|
private var recentOutput = Data()
|
|
private var authenticationPromptObserved = false
|
|
private var lastStopReason: String?
|
|
private var lifecycleOperationInProgress = false
|
|
private var lifecycleWaiters: [CheckedContinuation<Void, Never>] = []
|
|
private var exclusiveProbeWaiters: [CheckedContinuation<Void, Never>] = []
|
|
|
|
init(dependencies: Dependencies = .live()) {
|
|
self.dependencies = dependencies
|
|
self.sessionIdleWindow = dependencies.idleWindow
|
|
}
|
|
|
|
/// The pid of the running ``agy`` process, exposed so callers can discover
|
|
/// its listening ports via `lsof`.
|
|
var pid: pid_t? {
|
|
guard let proc = self.process, proc.isRunning else { return nil }
|
|
return proc.pid
|
|
}
|
|
|
|
/// Whether the managed process is alive and matches ``binaryPath``.
|
|
var isRunning: Bool {
|
|
guard let proc = self.process, proc.isRunning, self.binaryPath != nil else { return false }
|
|
return true
|
|
}
|
|
|
|
var failureCountForTesting: Int {
|
|
self.consecutiveProbeFailures
|
|
}
|
|
|
|
var idleWindowForTesting: TimeInterval {
|
|
self.sessionIdleWindow
|
|
}
|
|
|
|
var activeProbeCountForTesting: Int {
|
|
self.activeProbeCount
|
|
}
|
|
|
|
var lastStopReasonForTesting: String? {
|
|
self.lastStopReason
|
|
}
|
|
|
|
// MARK: Lifecycle
|
|
|
|
/// Mark a probe as active and ensure a warm ``agy`` is running on the given binary path.
|
|
///
|
|
/// Callers must balance this with ``finishProbe(success:resetAfterFetch:)`` so
|
|
/// idle/reset cleanup cannot kill the process while its ports are being probed.
|
|
/// If previous probes repeatedly failed while the process stayed alive, this
|
|
/// force-relaunches instead of reusing a wedged HTTPS server forever.
|
|
func beginProbe(binary: String, idleWindow: TimeInterval? = nil) async throws -> pid_t {
|
|
self.activeProbeCount += 1
|
|
if let idleWindow, idleWindow > 0 {
|
|
self.sessionIdleWindow = max(self.dependencies.idleWindow, idleWindow)
|
|
}
|
|
self.cancelIdleTimer()
|
|
do {
|
|
return try await self.withLifecycleOperation {
|
|
let pid = try await self.ensureStartedLocked(binary: binary)
|
|
self.activeSessionProbeCount += 1
|
|
return pid
|
|
}
|
|
} catch {
|
|
self.activeProbeCount = max(0, self.activeProbeCount - 1)
|
|
self.notifyExclusiveProbeWaitersIfNeeded()
|
|
if self.activeProbeCount == 0, self.resetRequestedWhenIdle {
|
|
await self.withLifecycleOperation {
|
|
guard self.activeProbeCount == 0 else {
|
|
self.resetRequestedWhenIdle = true
|
|
return
|
|
}
|
|
let forceTerminate = self.hardResetRequestedWhenIdle || self.consecutiveProbeFailures > 0
|
|
self.resetRequestedWhenIdle = false
|
|
self.hardResetRequestedWhenIdle = false
|
|
await self.stopCurrentSessionLocked(
|
|
reason: "deferred reset after failed begin",
|
|
clearRecord: true,
|
|
graceful: !forceTerminate)
|
|
}
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/// Record probe completion and either keep the session warm for the bounded
|
|
/// idle window or tear it down immediately for one-shot CLI invocations.
|
|
func finishProbe(success: Bool, resetAfterFetch: Bool, forceTerminate: Bool = false) async {
|
|
if success {
|
|
self.consecutiveProbeFailures = 0
|
|
} else {
|
|
self.consecutiveProbeFailures += 1
|
|
}
|
|
|
|
self.activeProbeCount = max(0, self.activeProbeCount - 1)
|
|
self.activeSessionProbeCount = max(0, self.activeSessionProbeCount - 1)
|
|
self.notifyExclusiveProbeWaitersIfNeeded()
|
|
let shouldForceStopUnhealthy = !success &&
|
|
self.consecutiveProbeFailures >= max(1, self.dependencies.failureRelaunchThreshold)
|
|
let shouldReset = forceTerminate || resetAfterFetch || self.resetRequestedWhenIdle || shouldForceStopUnhealthy
|
|
let currentResetCause = Self.resetCause(
|
|
authenticationRequired: forceTerminate,
|
|
resetAfterFetch: resetAfterFetch,
|
|
shouldForceStopUnhealthy: shouldForceStopUnhealthy)
|
|
|
|
guard self.activeProbeCount == 0 else {
|
|
if shouldReset {
|
|
self.resetRequestedWhenIdle = true
|
|
self.pendingResetCause = Self.strongestResetCause(self.pendingResetCause, currentResetCause)
|
|
}
|
|
if shouldReset, !success || forceTerminate {
|
|
self.hardResetRequestedWhenIdle = true
|
|
}
|
|
return
|
|
}
|
|
|
|
if shouldReset {
|
|
let shouldForceTerminate = !success || forceTerminate || self.hardResetRequestedWhenIdle
|
|
let resetCause = Self.strongestResetCause(self.pendingResetCause, currentResetCause)
|
|
await self.withLifecycleOperation {
|
|
guard self.activeProbeCount == 0 else {
|
|
self.resetRequestedWhenIdle = true
|
|
self.pendingResetCause = Self.strongestResetCause(self.pendingResetCause, resetCause)
|
|
if shouldForceTerminate {
|
|
self.hardResetRequestedWhenIdle = true
|
|
}
|
|
return
|
|
}
|
|
self.resetRequestedWhenIdle = false
|
|
self.hardResetRequestedWhenIdle = false
|
|
self.pendingResetCause = nil
|
|
await self.stopCurrentSessionLocked(
|
|
reason: resetCause.message,
|
|
clearRecord: true,
|
|
graceful: !shouldForceTerminate)
|
|
}
|
|
} else {
|
|
self.armIdleTimer()
|
|
}
|
|
}
|
|
|
|
static func resetCause(
|
|
authenticationRequired: Bool,
|
|
resetAfterFetch: Bool,
|
|
shouldForceStopUnhealthy: Bool) -> ResetCause
|
|
{
|
|
if authenticationRequired {
|
|
.authentication
|
|
} else if shouldForceStopUnhealthy {
|
|
.unhealthy
|
|
} else if resetAfterFetch {
|
|
.oneShotCLI
|
|
} else {
|
|
.deferred
|
|
}
|
|
}
|
|
|
|
static func strongestResetCause(_ existing: ResetCause?, _ incoming: ResetCause) -> ResetCause {
|
|
guard let existing else { return incoming }
|
|
return existing.rawValue >= incoming.rawValue ? existing : incoming
|
|
}
|
|
|
|
/// Ensure a warm ``agy`` is running on the given binary path.
|
|
///
|
|
/// - If the process is already alive with the same binary, this returns immediately.
|
|
/// - If the process died, the binary changed, or repeated probes failed, it tears down the old one first.
|
|
/// - Returns the process identifier for port discovery.
|
|
func ensureStarted(binary: String) async throws -> pid_t {
|
|
try await self.withLifecycleOperation {
|
|
try await self.ensureStartedLocked(binary: binary)
|
|
}
|
|
}
|
|
|
|
/// Request teardown. If a probe is in flight, cleanup is deferred until the
|
|
/// matching ``finishProbe(success:resetAfterFetch:)`` call.
|
|
func reset() async {
|
|
self.cancelIdleTimer()
|
|
guard self.activeProbeCount == 0 else {
|
|
self.resetRequestedWhenIdle = true
|
|
return
|
|
}
|
|
await self.withLifecycleOperation {
|
|
guard self.activeProbeCount == 0 else {
|
|
self.resetRequestedWhenIdle = true
|
|
return
|
|
}
|
|
self.resetRequestedWhenIdle = false
|
|
let forceTerminate = self.hardResetRequestedWhenIdle || self.consecutiveProbeFailures > 0
|
|
self.hardResetRequestedWhenIdle = false
|
|
await self.stopCurrentSessionLocked(
|
|
reason: "manual reset",
|
|
clearRecord: true,
|
|
graceful: !forceTerminate)
|
|
}
|
|
}
|
|
|
|
/// Drain PTY output into one rolling buffer shared by concurrent probes.
|
|
func drainOutput() -> Data {
|
|
var searchableOutput = self.recentOutput
|
|
if let output = self.process?.drainOutput(), !output.isEmpty {
|
|
searchableOutput.append(output)
|
|
self.recentOutput = Data(searchableOutput.suffix(4096))
|
|
}
|
|
|
|
if !self.authenticationPromptObserved,
|
|
AntigravityCLIAuthenticationPrompt.contains(searchableOutput)
|
|
{
|
|
self.authenticationPromptObserved = true
|
|
}
|
|
if self.authenticationPromptObserved,
|
|
!AntigravityCLIAuthenticationPrompt.contains(searchableOutput)
|
|
{
|
|
searchableOutput.append(AntigravityCLIAuthenticationPrompt.evidence)
|
|
}
|
|
return searchableOutput
|
|
}
|
|
|
|
// MARK: Errors
|
|
|
|
enum SessionError: LocalizedError {
|
|
case launchFailed(String)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case let .launchFailed(msg): "Failed to launch Antigravity CLI session: \(msg)"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Private
|
|
|
|
private func withLifecycleOperation<T>(_ operation: () async throws -> T) async throws -> T {
|
|
await self.acquireLifecycleOperation()
|
|
defer { self.releaseLifecycleOperation() }
|
|
return try await operation()
|
|
}
|
|
|
|
private func withLifecycleOperation(_ operation: () async -> Void) async {
|
|
await self.acquireLifecycleOperation()
|
|
defer { self.releaseLifecycleOperation() }
|
|
await operation()
|
|
}
|
|
|
|
private func acquireLifecycleOperation() async {
|
|
guard self.lifecycleOperationInProgress else {
|
|
self.lifecycleOperationInProgress = true
|
|
return
|
|
}
|
|
await withCheckedContinuation { continuation in
|
|
self.lifecycleWaiters.append(continuation)
|
|
}
|
|
}
|
|
|
|
private func releaseLifecycleOperation() {
|
|
guard !self.lifecycleWaiters.isEmpty else {
|
|
self.lifecycleOperationInProgress = false
|
|
return
|
|
}
|
|
let next = self.lifecycleWaiters.removeFirst()
|
|
next.resume()
|
|
}
|
|
|
|
private func waitForExclusiveProbeIfNeeded() async {
|
|
while self.activeSessionProbeCount > 0 {
|
|
await withCheckedContinuation { continuation in
|
|
self.exclusiveProbeWaiters.append(continuation)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func notifyExclusiveProbeWaitersIfNeeded() {
|
|
guard self.activeSessionProbeCount == 0, !self.exclusiveProbeWaiters.isEmpty else { return }
|
|
let waiters = self.exclusiveProbeWaiters
|
|
self.exclusiveProbeWaiters.removeAll()
|
|
for waiter in waiters {
|
|
waiter.resume()
|
|
}
|
|
}
|
|
|
|
private func ensureStartedLocked(binary: String) async throws -> pid_t {
|
|
while true {
|
|
if let proc = self.process,
|
|
proc.isRunning,
|
|
self.binaryPath == binary,
|
|
!self.resetRequestedWhenIdle,
|
|
!self.hardResetRequestedWhenIdle,
|
|
self.consecutiveProbeFailures < max(1, self.dependencies.failureRelaunchThreshold)
|
|
{
|
|
try? self.dependencies.launchLock.withLock {
|
|
self.reapRecordedSessionsIfNeeded()
|
|
}
|
|
self.persistCurrentRecordIfNeeded(proc, binary: binary)
|
|
Self.log.debug("Antigravity CLI session reused", metadata: ["pid": "\(proc.pid)"])
|
|
return proc.pid
|
|
}
|
|
|
|
if self.process != nil {
|
|
if self.activeSessionProbeCount > 0 {
|
|
await self.waitForExclusiveProbeIfNeeded()
|
|
continue
|
|
}
|
|
|
|
let reason = self.consecutiveProbeFailures >= max(1, self.dependencies.failureRelaunchThreshold)
|
|
? "relaunching unhealthy session"
|
|
: "replacing stale session"
|
|
let forceTerminate = self.hardResetRequestedWhenIdle || self.consecutiveProbeFailures > 0
|
|
self.resetRequestedWhenIdle = false
|
|
self.hardResetRequestedWhenIdle = false
|
|
await self.stopCurrentSessionLocked(
|
|
reason: reason,
|
|
clearRecord: true,
|
|
graceful: !forceTerminate)
|
|
}
|
|
|
|
let binaryName = URL(fileURLWithPath: binary).lastPathComponent
|
|
let launch: (Bool) throws -> LaunchOutcome = { canPersistRecord in
|
|
if canPersistRecord {
|
|
self.prepareRecordStoreForLaunch()
|
|
}
|
|
guard self.dependencies.beginAppShutdownTrackedLaunch() else {
|
|
throw SessionError.launchFailed("App shutdown in progress")
|
|
}
|
|
let launched: any AntigravityCLIProcessHandle
|
|
do {
|
|
launched = try self.dependencies.launcher.launch(binary: binary)
|
|
} catch {
|
|
self.dependencies.endAppShutdownTrackedLaunch()
|
|
throw error
|
|
}
|
|
let launchedPID = launched.pid
|
|
guard self.dependencies.registerForAppShutdown(launchedPID, binaryName) else {
|
|
return LaunchOutcome(
|
|
pid: launchedPID,
|
|
rejectedProcess: launched,
|
|
rejectionMessage: "App shutdown in progress",
|
|
holdsLaunchReservation: true)
|
|
}
|
|
|
|
let processGroup = launched.processGroup ?? launched.assignProcessGroup()
|
|
self.dependencies.updateAppShutdownProcessGroup(launchedPID, processGroup)
|
|
|
|
self.process = launched
|
|
self.binaryPath = binary
|
|
self.recentOutput.removeAll(keepingCapacity: true)
|
|
self.authenticationPromptObserved = false
|
|
self.consecutiveProbeFailures = 0
|
|
self.sessionGeneration &+= 1
|
|
if canPersistRecord {
|
|
_ = self.persistRecord(pid: launchedPID, binary: binary, processGroup: processGroup)
|
|
} else {
|
|
self.persistedProcessIdentity = nil
|
|
}
|
|
self.dependencies.endAppShutdownTrackedLaunch()
|
|
return LaunchOutcome(
|
|
pid: launchedPID,
|
|
rejectedProcess: nil,
|
|
rejectionMessage: nil,
|
|
holdsLaunchReservation: false)
|
|
}
|
|
|
|
var lockedLaunch: Result<LaunchOutcome, Error>?
|
|
var lockFailure: Error?
|
|
do {
|
|
try self.dependencies.launchLock.withLock {
|
|
lockedLaunch = Result { try launch(true) }
|
|
}
|
|
} catch {
|
|
lockFailure = error
|
|
}
|
|
|
|
let outcome: LaunchOutcome
|
|
if let lockFailure {
|
|
Self.log.warning(
|
|
"Antigravity CLI session coordination unavailable",
|
|
metadata: ["error": lockFailure.localizedDescription])
|
|
outcome = try launch(false)
|
|
} else if let lockedLaunch {
|
|
outcome = try lockedLaunch.get()
|
|
} else {
|
|
throw SessionError.launchFailed("CLI session launch did not complete")
|
|
}
|
|
if let rejectedProcess = outcome.rejectedProcess {
|
|
await self.terminateLaunchedProcess(rejectedProcess, graceful: false)
|
|
if outcome.holdsLaunchReservation {
|
|
self.dependencies.endAppShutdownTrackedLaunch()
|
|
}
|
|
throw SessionError.launchFailed(outcome.rejectionMessage ?? "App shutdown in progress")
|
|
}
|
|
|
|
Self.log.debug(
|
|
"Antigravity CLI session started",
|
|
metadata: [
|
|
"binary": binaryName,
|
|
"pid": "\(outcome.pid)",
|
|
])
|
|
return outcome.pid
|
|
}
|
|
}
|
|
|
|
private func prepareRecordStoreForLaunch() {
|
|
guard self.process == nil || self.persistedProcessIdentity == nil else { return }
|
|
self.reapRecordedSessionsIfNeeded()
|
|
}
|
|
|
|
private func persistCurrentRecordIfNeeded(_ proc: any AntigravityCLIProcessHandle, binary: String) {
|
|
guard self.persistedProcessIdentity == nil else { return }
|
|
try? self.dependencies.launchLock.withLock {
|
|
self.prepareRecordStoreForLaunch()
|
|
_ = self.persistRecord(pid: proc.pid, binary: binary, processGroup: proc.processGroup)
|
|
}
|
|
}
|
|
|
|
private func cancelIdleTimer() {
|
|
self.idleTask?.cancel()
|
|
self.idleTask = nil
|
|
}
|
|
|
|
private func armIdleTimer() {
|
|
guard self.process != nil, self.sessionIdleWindow > 0 else { return }
|
|
self.cancelIdleTimer()
|
|
let generation = self.sessionGeneration
|
|
let nanoseconds = Self.nanoseconds(from: self.sessionIdleWindow)
|
|
let sleep = self.dependencies.sleep
|
|
self.idleTask = Task { [weak self] in
|
|
do {
|
|
try await sleep(nanoseconds)
|
|
await self?.stopIfIdle(generation: generation)
|
|
} catch {
|
|
// Cancellation is the normal path when a refresh reuses the warm session.
|
|
}
|
|
}
|
|
}
|
|
|
|
private func stopIfIdle(generation: UInt64) async {
|
|
await self.withLifecycleOperation {
|
|
guard generation == self.sessionGeneration else { return }
|
|
guard self.activeProbeCount == 0 else {
|
|
self.armIdleTimer()
|
|
return
|
|
}
|
|
let forceTerminate = self.hardResetRequestedWhenIdle || self.consecutiveProbeFailures > 0
|
|
self.resetRequestedWhenIdle = false
|
|
self.hardResetRequestedWhenIdle = false
|
|
await self.stopCurrentSessionLocked(
|
|
reason: "idle timeout",
|
|
clearRecord: true,
|
|
graceful: !forceTerminate)
|
|
}
|
|
}
|
|
|
|
private func stopCurrentSessionLocked(reason: String, clearRecord: Bool, graceful: Bool = true) async {
|
|
self.cancelIdleTimer()
|
|
let effectiveReason = self.pendingResetCause?.message ?? reason
|
|
self.pendingResetCause = nil
|
|
self.lastStopReason = effectiveReason
|
|
guard let proc = self.process else {
|
|
if clearRecord {
|
|
try? self.dependencies.launchLock.withLock {
|
|
self.reapRecordedSessionsIfNeeded()
|
|
}
|
|
}
|
|
self.sessionIdleWindow = self.dependencies.idleWindow
|
|
self.recentOutput.removeAll(keepingCapacity: true)
|
|
self.authenticationPromptObserved = false
|
|
return
|
|
}
|
|
|
|
let pid = proc.pid
|
|
let identity = self.persistedProcessIdentity
|
|
Self.log.debug("Antigravity CLI session stopping", metadata: ["pid": "\(pid)", "reason": "\(effectiveReason)"])
|
|
|
|
self.process = nil
|
|
self.binaryPath = nil
|
|
self.persistedProcessIdentity = nil
|
|
self.sessionIdleWindow = self.dependencies.idleWindow
|
|
self.recentOutput.removeAll(keepingCapacity: true)
|
|
self.authenticationPromptObserved = false
|
|
self.sessionGeneration &+= 1
|
|
|
|
await self.terminateLaunchedProcess(proc, graceful: graceful)
|
|
self.dependencies.unregisterForAppShutdown(pid)
|
|
if clearRecord {
|
|
self.removeRecordIfMatches(pid: pid, identity: identity)
|
|
}
|
|
}
|
|
|
|
private func terminateLaunchedProcess(
|
|
_ proc: any AntigravityCLIProcessHandle,
|
|
graceful: Bool = true) async
|
|
{
|
|
if graceful {
|
|
try? proc.sendExit()
|
|
}
|
|
proc.closePTY()
|
|
|
|
let descendants = proc.descendantPIDs()
|
|
if proc.isRunning {
|
|
proc.terminateRoot()
|
|
}
|
|
proc.terminateTree(signal: SIGTERM, knownDescendants: descendants)
|
|
|
|
let gracePeriod = self.dependencies.terminationGracePeriod
|
|
if gracePeriod > 0 {
|
|
let deadline = self.dependencies.now().addingTimeInterval(gracePeriod)
|
|
while proc.isRunning, self.dependencies.now() < deadline {
|
|
try? await self.dependencies.sleep(100_000_000)
|
|
}
|
|
}
|
|
|
|
if proc.isRunning {
|
|
proc.terminateTree(signal: SIGKILL, knownDescendants: descendants)
|
|
await self.waitUntilProcessExits(proc, timeout: 1)
|
|
} else {
|
|
proc.killDescendants(descendants)
|
|
}
|
|
}
|
|
|
|
private func waitUntilProcessExits(_ proc: any AntigravityCLIProcessHandle, timeout: TimeInterval) async {
|
|
let deadline = self.dependencies.now().addingTimeInterval(timeout)
|
|
while proc.isRunning, self.dependencies.now() < deadline {
|
|
try? await self.dependencies.sleep(50_000_000)
|
|
}
|
|
_ = proc.isRunning
|
|
}
|
|
|
|
@discardableResult
|
|
private func persistRecord(pid: pid_t, binary: String, processGroup: pid_t?) -> Bool {
|
|
guard let identity = self.dependencies.identityProvider.identity(for: pid) else {
|
|
self.persistedProcessIdentity = nil
|
|
return false
|
|
}
|
|
let ownerPID = self.dependencies.currentProcessID()
|
|
let ownerIdentity = self.dependencies.identityProvider.identity(for: ownerPID)
|
|
let record = AntigravityCLISessionRecord(
|
|
pid: pid,
|
|
requestedBinaryPath: binary,
|
|
executablePath: identity.executablePath,
|
|
startEpoch: identity.startEpoch,
|
|
processGroup: processGroup,
|
|
ownerPID: ownerPID,
|
|
ownerExecutablePath: ownerIdentity?.executablePath,
|
|
ownerStartEpoch: ownerIdentity?.startEpoch)
|
|
do {
|
|
try self.dependencies.recordStore.save(record)
|
|
self.persistedProcessIdentity = identity
|
|
return true
|
|
} catch {
|
|
self.persistedProcessIdentity = nil
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func reapRecordedSessionsIfNeeded() {
|
|
guard let records = try? self.dependencies.recordStore.load() else { return }
|
|
for record in records {
|
|
guard let liveIdentity = self.dependencies.identityProvider.identity(for: record.pid),
|
|
liveIdentity.executablePath == record.executablePath,
|
|
abs(liveIdentity.startEpoch - record.startEpoch) < 0.001
|
|
else {
|
|
try? self.dependencies.recordStore.remove(record)
|
|
continue
|
|
}
|
|
if let proc = self.process, proc.pid == record.pid {
|
|
self.persistedProcessIdentity = liveIdentity
|
|
continue
|
|
}
|
|
if let ownerPID = record.ownerPID,
|
|
ownerPID != self.dependencies.currentProcessID(),
|
|
let ownerExecutablePath = record.ownerExecutablePath,
|
|
let ownerStartEpoch = record.ownerStartEpoch,
|
|
let liveOwnerIdentity = self.dependencies.identityProvider.identity(for: ownerPID),
|
|
liveOwnerIdentity.executablePath == ownerExecutablePath,
|
|
abs(liveOwnerIdentity.startEpoch - ownerStartEpoch) < 0.001
|
|
{
|
|
continue
|
|
}
|
|
|
|
let knownDescendants = self.dependencies.descendantPIDs(record.pid)
|
|
Self.log.debug("Reaping stale Antigravity CLI session", metadata: ["pid": "\(record.pid)"])
|
|
self.dependencies.terminateProcessTree(record.pid, record.processGroup, SIGTERM, knownDescendants)
|
|
self.dependencies.terminateProcessTree(record.pid, record.processGroup, SIGKILL, knownDescendants)
|
|
try? self.dependencies.recordStore.remove(record)
|
|
}
|
|
}
|
|
|
|
private func removeRecordIfMatches(pid: pid_t, identity: AntigravityCLIProcessIdentity?) {
|
|
try? self.dependencies.launchLock.withLock {
|
|
guard let identity, let records = try? self.dependencies.recordStore.load() else { return }
|
|
for record in records
|
|
where record.pid == pid &&
|
|
record.executablePath == identity.executablePath &&
|
|
abs(record.startEpoch - identity.startEpoch) < 0.001
|
|
{
|
|
try? self.dependencies.recordStore.remove(record)
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func nanoseconds(from interval: TimeInterval) -> UInt64 {
|
|
guard interval > 0 else { return 0 }
|
|
guard interval.isFinite else { return UInt64.max }
|
|
let nanoseconds = interval * 1_000_000_000
|
|
guard nanoseconds < TimeInterval(UInt64.max) else { return UInt64.max }
|
|
return UInt64(nanoseconds)
|
|
}
|
|
}
|
|
|
|
// MARK: - Production Process Implementation
|
|
|
|
struct AntigravityPTYProcessLauncher: AntigravityCLIProcessLaunching {
|
|
static func defaultSignalsForSpawn() -> sigset_t {
|
|
var signals = sigset_t()
|
|
sigemptyset(&signals)
|
|
sigaddset(&signals, SIGINT)
|
|
sigaddset(&signals, SIGTERM)
|
|
sigaddset(&signals, SIGHUP)
|
|
return signals
|
|
}
|
|
|
|
static func spawnWithTextBusyRetry(
|
|
maxAttempts: Int = 3,
|
|
retryDelay: TimeInterval = 0.01,
|
|
spawn: () -> Int32) -> Int32
|
|
{
|
|
var result = spawn()
|
|
guard maxAttempts > 1 else { return result }
|
|
|
|
for _ in 1..<maxAttempts where result == ETXTBSY {
|
|
if retryDelay > 0 {
|
|
Thread.sleep(forTimeInterval: retryDelay)
|
|
}
|
|
result = spawn()
|
|
}
|
|
return result
|
|
}
|
|
|
|
func launch(binary: String) throws -> any AntigravityCLIProcessHandle {
|
|
try self.launch(binary: binary, arguments: [])
|
|
}
|
|
|
|
func launch(binary: String, arguments: [String]) throws -> any AntigravityCLIProcessHandle {
|
|
var primaryFD: Int32 = -1
|
|
var secondaryFD: Int32 = -1
|
|
var win = winsize(ws_row: 50, ws_col: 160, ws_xpixel: 0, ws_ypixel: 0)
|
|
guard openpty(&primaryFD, &secondaryFD, nil, nil, &win) == 0 else {
|
|
throw AntigravityCLISession.SessionError.launchFailed("openpty failed")
|
|
}
|
|
_ = fcntl(primaryFD, F_SETFL, O_NONBLOCK)
|
|
|
|
let primaryHandle = FileHandle(fileDescriptor: primaryFD, closeOnDealloc: true)
|
|
let secondaryHandle = FileHandle(fileDescriptor: secondaryFD, closeOnDealloc: true)
|
|
|
|
#if canImport(Darwin)
|
|
var fileActions: posix_spawn_file_actions_t?
|
|
#else
|
|
var fileActions = posix_spawn_file_actions_t()
|
|
#endif
|
|
guard posix_spawn_file_actions_init(&fileActions) == 0 else {
|
|
try? primaryHandle.close()
|
|
try? secondaryHandle.close()
|
|
throw AntigravityCLISession.SessionError.launchFailed("posix_spawn_file_actions_init failed")
|
|
}
|
|
defer { posix_spawn_file_actions_destroy(&fileActions) }
|
|
|
|
posix_spawn_file_actions_adddup2(&fileActions, secondaryFD, 0)
|
|
posix_spawn_file_actions_adddup2(&fileActions, secondaryFD, 1)
|
|
posix_spawn_file_actions_adddup2(&fileActions, secondaryFD, 2)
|
|
posix_spawn_file_actions_addclose(&fileActions, primaryFD)
|
|
posix_spawn_file_actions_addclose(&fileActions, secondaryFD)
|
|
|
|
let homeDirectory = NSHomeDirectory()
|
|
_ = homeDirectory.withCString { path in
|
|
posix_spawn_file_actions_addchdir_np(&fileActions, path)
|
|
}
|
|
#if canImport(Glibc) || canImport(Musl)
|
|
do {
|
|
try PosixSpawnFileActionsCloseFrom.addCloseFrom(&fileActions, startingAt: 3)
|
|
} catch {
|
|
try? primaryHandle.close()
|
|
try? secondaryHandle.close()
|
|
throw AntigravityCLISession.SessionError.launchFailed(error.localizedDescription)
|
|
}
|
|
#endif
|
|
|
|
#if canImport(Darwin)
|
|
var attr: posix_spawnattr_t?
|
|
#else
|
|
var attr = posix_spawnattr_t()
|
|
#endif
|
|
guard posix_spawnattr_init(&attr) == 0 else {
|
|
try? primaryHandle.close()
|
|
try? secondaryHandle.close()
|
|
throw AntigravityCLISession.SessionError.launchFailed("posix_spawnattr_init failed")
|
|
}
|
|
defer { posix_spawnattr_destroy(&attr) }
|
|
var defaultSignals = Self.defaultSignalsForSpawn()
|
|
posix_spawnattr_setsigdefault(&attr, &defaultSignals)
|
|
#if canImport(Darwin)
|
|
let spawnFlags = POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_SETSIGDEF | POSIX_SPAWN_CLOEXEC_DEFAULT
|
|
#else
|
|
let spawnFlags = POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_SETSIGDEF
|
|
#endif
|
|
posix_spawnattr_setflags(&attr, Int16(spawnFlags))
|
|
posix_spawnattr_setpgroup(&attr, 0)
|
|
|
|
var env = TTYCommandRunner.enrichedEnvironment()
|
|
env["PWD"] = NSHomeDirectory()
|
|
env["TERM"] = "xterm-256color"
|
|
|
|
var cArgs = ([binary] + arguments).map { strdup($0) as UnsafeMutablePointer<CChar>? }
|
|
cArgs.append(nil)
|
|
defer {
|
|
for arg in cArgs {
|
|
if let arg {
|
|
free(arg)
|
|
}
|
|
}
|
|
}
|
|
|
|
var cEnv: [UnsafeMutablePointer<CChar>?] = env.map { key, value in
|
|
strdup("\(key)=\(value)")
|
|
}
|
|
cEnv.append(nil)
|
|
defer {
|
|
for entry in cEnv {
|
|
if let entry {
|
|
free(entry)
|
|
}
|
|
}
|
|
}
|
|
|
|
var pid: pid_t = 0
|
|
let spawnResult = Self.spawnWithTextBusyRetry {
|
|
binary.withCString { execPath in
|
|
posix_spawn(&pid, execPath, &fileActions, &attr, cArgs, cEnv)
|
|
}
|
|
}
|
|
guard spawnResult == 0 else {
|
|
try? primaryHandle.close()
|
|
try? secondaryHandle.close()
|
|
throw AntigravityCLISession.SessionError.launchFailed(String(cString: strerror(spawnResult)))
|
|
}
|
|
|
|
return AntigravitySpawnedPTYProcessHandle(
|
|
pid: pid,
|
|
processGroup: pid,
|
|
primaryFD: primaryFD,
|
|
primaryHandle: primaryHandle,
|
|
secondaryHandle: secondaryHandle)
|
|
}
|
|
}
|
|
|
|
final class AntigravitySpawnedPTYProcessHandle: AntigravityCLIProcessHandle, @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private let processPID: pid_t
|
|
private let processGroupID: pid_t
|
|
private let primaryFD: Int32
|
|
private let primaryHandle: FileHandle
|
|
private let secondaryHandle: FileHandle
|
|
private var reaped = false
|
|
|
|
init(
|
|
pid: pid_t,
|
|
processGroup: pid_t,
|
|
primaryFD: Int32,
|
|
primaryHandle: FileHandle,
|
|
secondaryHandle: FileHandle)
|
|
{
|
|
self.processPID = pid
|
|
self.processGroupID = processGroup
|
|
self.primaryFD = primaryFD
|
|
self.primaryHandle = primaryHandle
|
|
self.secondaryHandle = secondaryHandle
|
|
}
|
|
|
|
var pid: pid_t {
|
|
self.processPID
|
|
}
|
|
|
|
var isRunning: Bool {
|
|
self.lock.lock()
|
|
if self.reaped {
|
|
self.lock.unlock()
|
|
return false
|
|
}
|
|
self.lock.unlock()
|
|
|
|
var status: Int32 = 0
|
|
let result = waitpid(self.processPID, &status, WNOHANG)
|
|
|
|
self.lock.lock()
|
|
defer { self.lock.unlock() }
|
|
switch result {
|
|
case 0:
|
|
return true
|
|
case self.processPID:
|
|
self.reaped = true
|
|
return false
|
|
case -1 where errno == ECHILD:
|
|
self.reaped = true
|
|
return false
|
|
default:
|
|
return kill(self.processPID, 0) == 0 || errno == EPERM
|
|
}
|
|
}
|
|
|
|
var processGroup: pid_t? {
|
|
self.processGroupID
|
|
}
|
|
|
|
func assignProcessGroup() -> pid_t? {
|
|
self.processGroupID
|
|
}
|
|
|
|
func sendExit() throws {
|
|
try self.writeAllToPrimary(Data("/exit\r".utf8))
|
|
}
|
|
|
|
func closePTY() {
|
|
try? self.primaryHandle.close()
|
|
try? self.secondaryHandle.close()
|
|
}
|
|
|
|
func terminateRoot() {
|
|
kill(self.processPID, SIGTERM)
|
|
}
|
|
|
|
func killRoot() {
|
|
kill(self.processPID, SIGKILL)
|
|
}
|
|
|
|
func descendantPIDs() -> [pid_t] {
|
|
TTYProcessTreeTerminator.descendantPIDs(of: self.processPID)
|
|
}
|
|
|
|
func terminateTree(signal: Int32, knownDescendants: [pid_t]) {
|
|
TTYProcessTreeTerminator.terminateProcessTree(
|
|
rootPID: self.processPID,
|
|
processGroup: self.processGroupID,
|
|
signal: signal,
|
|
knownDescendants: knownDescendants)
|
|
}
|
|
|
|
func killDescendants(_ descendants: [pid_t]) {
|
|
for pid in descendants where pid > 0 {
|
|
kill(pid, SIGKILL)
|
|
}
|
|
}
|
|
|
|
func drainOutput() -> Data {
|
|
var tmp = [UInt8](repeating: 0, count: 8192)
|
|
var output: [UInt8] = []
|
|
for _ in 0..<64 {
|
|
let n = read(self.primaryFD, &tmp, tmp.count)
|
|
if n > 0 {
|
|
output.append(contentsOf: tmp.prefix(n))
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
return Data(output)
|
|
}
|
|
|
|
private func writeAllToPrimary(_ data: Data) throws {
|
|
data.withUnsafeBytes { rawBytes in
|
|
guard let baseAddress = rawBytes.baseAddress else { return }
|
|
var offset = 0
|
|
var retries = 0
|
|
while offset < rawBytes.count {
|
|
let written = write(self.primaryFD, baseAddress.advanced(by: offset), rawBytes.count - offset)
|
|
if written > 0 {
|
|
offset += written
|
|
retries = 0
|
|
continue
|
|
}
|
|
if written == 0 { break }
|
|
|
|
let err = errno
|
|
if err == EINTR || err == EAGAIN || err == EWOULDBLOCK {
|
|
retries += 1
|
|
if retries > 200 { return }
|
|
usleep(5000)
|
|
continue
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Production Stale Session Identity + Storage
|
|
|
|
struct AntigravityProcessIdentityProvider: AntigravityCLIProcessIdentityProviding {
|
|
static var currentUserID: UInt32 {
|
|
UInt32(getuid())
|
|
}
|
|
|
|
func ownerUserID(for pid: pid_t) -> UInt32? {
|
|
#if canImport(Darwin)
|
|
var info = proc_bsdinfo()
|
|
let size = proc_pidinfo(
|
|
pid,
|
|
PROC_PIDTBSDINFO,
|
|
0,
|
|
&info,
|
|
Int32(MemoryLayout<proc_bsdinfo>.stride))
|
|
guard size == Int32(MemoryLayout<proc_bsdinfo>.stride) else { return nil }
|
|
return info.pbi_uid
|
|
#else
|
|
guard let status = try? String(contentsOfFile: "/proc/\(pid)/status", encoding: .utf8),
|
|
let uidLine = status.split(separator: "\n").first(where: { $0.hasPrefix("Uid:") }),
|
|
let owner = uidLine.split(whereSeparator: \.isWhitespace).dropFirst().first,
|
|
let userID = UInt32(owner)
|
|
else {
|
|
return nil
|
|
}
|
|
return userID
|
|
#endif
|
|
}
|
|
|
|
func identity(for pid: pid_t) -> AntigravityCLIProcessIdentity? {
|
|
#if canImport(Darwin)
|
|
var pathBuffer = [CChar](repeating: 0, count: 4096)
|
|
let pathLength = proc_pidpath(pid, &pathBuffer, UInt32(pathBuffer.count))
|
|
guard pathLength > 0 else { return nil }
|
|
let executablePath = pathBuffer.withUnsafeBufferPointer { buffer -> String? in
|
|
let rawBytes = UnsafeRawBufferPointer(start: buffer.baseAddress, count: Int(pathLength))
|
|
return String(bytes: rawBytes.prefix { $0 != 0 }, encoding: .utf8)
|
|
}
|
|
guard let executablePath, !executablePath.isEmpty else { return nil }
|
|
|
|
var info = proc_bsdinfo()
|
|
let size = proc_pidinfo(
|
|
pid,
|
|
PROC_PIDTBSDINFO,
|
|
0,
|
|
&info,
|
|
Int32(MemoryLayout<proc_bsdinfo>.stride))
|
|
guard size == Int32(MemoryLayout<proc_bsdinfo>.stride) else { return nil }
|
|
let startEpoch = TimeInterval(info.pbi_start_tvsec) + (TimeInterval(info.pbi_start_tvusec) / 1_000_000)
|
|
return AntigravityCLIProcessIdentity(executablePath: executablePath, startEpoch: startEpoch)
|
|
#else
|
|
let procDirectory = "/proc/\(pid)"
|
|
guard let executablePath = try? FileManager.default.destinationOfSymbolicLink(
|
|
atPath: "\(procDirectory)/exe"),
|
|
let stat = try? String(contentsOfFile: "\(procDirectory)/stat", encoding: .utf8),
|
|
let closeParen = stat.lastIndex(of: ")")
|
|
else {
|
|
return nil
|
|
}
|
|
let fields = stat[stat.index(after: closeParen)...].split(whereSeparator: \.isWhitespace)
|
|
let clockTicksPerSecond = sysconf(Int32(_SC_CLK_TCK))
|
|
let systemStat = try? String(contentsOfFile: "/proc/stat", encoding: .utf8)
|
|
let bootEpoch = systemStat?
|
|
.split(separator: "\n")
|
|
.first { $0.hasPrefix("btime ") }?
|
|
.split(whereSeparator: \.isWhitespace)
|
|
.dropFirst()
|
|
.first
|
|
.flatMap { TimeInterval($0) }
|
|
guard fields.count > 19,
|
|
let startTicks = TimeInterval(fields[19]),
|
|
clockTicksPerSecond > 0,
|
|
let bootEpoch
|
|
else {
|
|
return nil
|
|
}
|
|
let startEpoch = bootEpoch + (startTicks / TimeInterval(clockTicksPerSecond))
|
|
return AntigravityCLIProcessIdentity(executablePath: executablePath, startEpoch: startEpoch)
|
|
#endif
|
|
}
|
|
}
|
|
|
|
final class AntigravityFileCLISessionRecordStore: AntigravityCLISessionRecordStoring, @unchecked Sendable {
|
|
private let fileURL: URL
|
|
private let fileManager: FileManager
|
|
|
|
init(
|
|
fileURL: URL = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
|
|
.appendingPathComponent(".codexbar", isDirectory: true)
|
|
.appendingPathComponent("antigravity", isDirectory: true)
|
|
.appendingPathComponent("agy-session.json"),
|
|
fileManager: FileManager = .default)
|
|
{
|
|
self.fileURL = fileURL
|
|
self.fileManager = fileManager
|
|
}
|
|
|
|
func load() throws -> [AntigravityCLISessionRecord] {
|
|
guard self.fileManager.fileExists(atPath: self.fileURL.path) else { return [] }
|
|
let data = try Data(contentsOf: self.fileURL)
|
|
let decoder = JSONDecoder()
|
|
if let records = try? decoder.decode([AntigravityCLISessionRecord].self, from: data) {
|
|
return records
|
|
}
|
|
return try [decoder.decode(AntigravityCLISessionRecord.self, from: data)]
|
|
}
|
|
|
|
func save(_ record: AntigravityCLISessionRecord) throws {
|
|
var records = (try? self.load()) ?? []
|
|
records.removeAll { Self.sameOwner($0, record) }
|
|
records.append(record)
|
|
try self.write(records)
|
|
}
|
|
|
|
func remove(_ record: AntigravityCLISessionRecord) throws {
|
|
var records = try self.load()
|
|
records.removeAll {
|
|
$0.pid == record.pid &&
|
|
$0.executablePath == record.executablePath &&
|
|
abs($0.startEpoch - record.startEpoch) < 0.001
|
|
}
|
|
if records.isEmpty {
|
|
guard self.fileManager.fileExists(atPath: self.fileURL.path) else { return }
|
|
try self.fileManager.removeItem(at: self.fileURL)
|
|
} else {
|
|
try self.write(records)
|
|
}
|
|
}
|
|
|
|
private func write(_ records: [AntigravityCLISessionRecord]) throws {
|
|
let directory = self.fileURL.deletingLastPathComponent()
|
|
try self.fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
let data = try JSONEncoder().encode(records)
|
|
try data.write(to: self.fileURL, options: [.atomic])
|
|
}
|
|
|
|
private static func sameOwner(
|
|
_ lhs: AntigravityCLISessionRecord,
|
|
_ rhs: AntigravityCLISessionRecord) -> Bool
|
|
{
|
|
if let lhsOwnerPID = lhs.ownerPID,
|
|
let rhsOwnerPID = rhs.ownerPID,
|
|
let lhsOwnerPath = lhs.ownerExecutablePath,
|
|
let rhsOwnerPath = rhs.ownerExecutablePath,
|
|
let lhsOwnerStart = lhs.ownerStartEpoch,
|
|
let rhsOwnerStart = rhs.ownerStartEpoch
|
|
{
|
|
return lhsOwnerPID == rhsOwnerPID &&
|
|
lhsOwnerPath == rhsOwnerPath &&
|
|
abs(lhsOwnerStart - rhsOwnerStart) < 0.001
|
|
}
|
|
return lhs.pid == rhs.pid &&
|
|
lhs.executablePath == rhs.executablePath &&
|
|
abs(lhs.startEpoch - rhs.startEpoch) < 0.001
|
|
}
|
|
}
|
|
|
|
final class AntigravityFileCLISessionLaunchLock: AntigravityCLISessionLaunchLocking, @unchecked Sendable {
|
|
private let fileURL: URL
|
|
private let fileManager: FileManager
|
|
|
|
init(
|
|
fileURL: URL = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
|
|
.appendingPathComponent(".codexbar", isDirectory: true)
|
|
.appendingPathComponent("antigravity", isDirectory: true)
|
|
.appendingPathComponent("agy-session.lock"),
|
|
fileManager: FileManager = .default)
|
|
{
|
|
self.fileURL = fileURL
|
|
self.fileManager = fileManager
|
|
}
|
|
|
|
func withLock<T>(_ operation: () throws -> T) throws -> T {
|
|
let directory = self.fileURL.deletingLastPathComponent()
|
|
try self.fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
let fd = open(self.fileURL.path, O_CREAT | O_RDWR | O_CLOEXEC, S_IRUSR | S_IWUSR)
|
|
guard fd >= 0 else {
|
|
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
|
}
|
|
defer {
|
|
_ = flock(fd, LOCK_UN)
|
|
close(fd)
|
|
}
|
|
|
|
while flock(fd, LOCK_EX) != 0 {
|
|
guard errno == EINTR else {
|
|
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
|
}
|
|
}
|
|
return try operation()
|
|
}
|
|
}
|