Files
steipete--codexbar/Tests/CodexBarTests/AntigravityCLISessionTests.swift
2026-07-13 12:22:33 +08:00

1549 lines
61 KiB
Swift

#if canImport(Darwin)
import Darwin
#else
import Glibc
#endif
import Foundation
import Testing
@testable import CodexBarCore
private final class FakeAntigravityProcessHandle: AntigravityCLIProcessHandle, @unchecked Sendable {
private let lock = NSLock()
let pid: pid_t
var descendants: [pid_t]
private var running: Bool
private let terminateRootStopsProcess: Bool
private var assignedProcessGroup: pid_t?
private var events: [String] = []
private var drainOutputChunks: [Data] = []
init(pid: pid_t, running: Bool = true, descendants: [pid_t] = [], terminateRootStopsProcess: Bool = true) {
self.pid = pid
self.running = running
self.descendants = descendants
self.terminateRootStopsProcess = terminateRootStopsProcess
}
var isRunning: Bool {
self.lock.lock()
let value = self.running
self.events.append("isRunning:\(value)")
self.lock.unlock()
return value
}
var processGroup: pid_t? {
self.lock.lock()
let value = self.assignedProcessGroup
self.lock.unlock()
return value
}
func assignProcessGroup() -> pid_t? {
self.lock.lock()
self.assignedProcessGroup = self.pid
self.events.append("assignProcessGroup")
self.lock.unlock()
return self.pid
}
func sendExit() throws {
self.append("sendExit")
}
func closePTY() {
self.append("closePTY")
}
func terminateRoot() {
self.lock.lock()
if self.terminateRootStopsProcess {
self.running = false
}
self.events.append("terminateRoot")
self.lock.unlock()
}
func killRoot() {
self.lock.lock()
self.running = false
self.events.append("killRoot")
self.lock.unlock()
}
func descendantPIDs() -> [pid_t] {
self.lock.lock()
let value = self.descendants
self.events.append("descendantPIDs")
self.lock.unlock()
return value
}
func terminateTree(signal: Int32, knownDescendants _: [pid_t]) {
self.lock.lock()
if signal == SIGKILL {
self.running = false
}
self.events.append("terminateTree:\(signal)")
self.lock.unlock()
}
func killDescendants(_ descendants: [pid_t]) {
self.append("killDescendants:\(descendants.map(String.init).joined(separator: ","))")
}
func drainOutput() -> Data {
self.lock.lock()
self.events.append("drainOutput")
let output = self.drainOutputChunks.isEmpty ? Data() : self.drainOutputChunks.removeFirst()
self.lock.unlock()
return output
}
func enqueueDrainOutput(_ output: Data) {
self.lock.lock()
self.drainOutputChunks.append(output)
self.lock.unlock()
}
func snapshotEvents() -> [String] {
self.lock.lock()
let value = self.events
self.lock.unlock()
return value
}
private func append(_ event: String) {
self.lock.lock()
self.events.append(event)
self.lock.unlock()
}
}
private final class FakeAntigravityProcessLauncher: AntigravityCLIProcessLaunching, @unchecked Sendable {
private let lock = NSLock()
private var nextPID: pid_t
private var launchError: Error?
private var launchedBinaries: [String] = []
private var terminateRootStopsProcess = true
private var handles: [FakeAntigravityProcessHandle] = []
init(nextPID: pid_t = 1) {
self.nextPID = nextPID
}
func launch(binary: String) throws -> any AntigravityCLIProcessHandle {
self.lock.lock()
defer { self.lock.unlock() }
if let launchError {
throw launchError
}
let handle = FakeAntigravityProcessHandle(
pid: self.nextPID,
descendants: [self.nextPID + 100],
terminateRootStopsProcess: self.terminateRootStopsProcess)
self.nextPID += 1
self.launchedBinaries.append(binary)
self.handles.append(handle)
return handle
}
func setLaunchError(_ error: Error?) {
self.lock.lock()
self.launchError = error
self.lock.unlock()
}
func setTerminateRootStopsProcess(_ value: Bool) {
self.lock.lock()
self.terminateRootStopsProcess = value
self.lock.unlock()
}
func launchedBinarySnapshot() -> [String] {
self.lock.lock()
let value = self.launchedBinaries
self.lock.unlock()
return value
}
func handleSnapshot() -> [FakeAntigravityProcessHandle] {
self.lock.lock()
let value = self.handles
self.lock.unlock()
return value
}
}
private final class FakeAntigravityIdentityProvider: AntigravityCLIProcessIdentityProviding, @unchecked Sendable {
private let lock = NSLock()
private var identities: [pid_t: AntigravityCLIProcessIdentity] = [:]
func setIdentity(pid: pid_t, executablePath: String, startEpoch: TimeInterval) {
self.lock.lock()
self.identities[pid] = AntigravityCLIProcessIdentity(executablePath: executablePath, startEpoch: startEpoch)
self.lock.unlock()
}
func removeIdentity(pid: pid_t) {
self.lock.lock()
self.identities[pid] = nil
self.lock.unlock()
}
func identity(for pid: pid_t) -> AntigravityCLIProcessIdentity? {
self.lock.lock()
let value = self.identities[pid]
self.lock.unlock()
return value
}
}
private final class MemoryAntigravitySessionRecordStore: AntigravityCLISessionRecordStoring, @unchecked Sendable {
private let lock = NSLock()
private var records: [AntigravityCLISessionRecord]
private let failSaves: Bool
private var saves = 0
private var removes = 0
init(record: AntigravityCLISessionRecord? = nil, failSaves: Bool = false) {
self.records = record.map { [$0] } ?? []
self.failSaves = failSaves
}
func load() throws -> [AntigravityCLISessionRecord] {
self.lock.lock()
let value = self.records
self.lock.unlock()
return value
}
func save(_ record: AntigravityCLISessionRecord) throws {
self.lock.lock()
guard !self.failSaves else {
self.lock.unlock()
throw CocoaError(.fileWriteNoPermission)
}
self.records.removeAll { existing in
if let existingOwnerPID = existing.ownerPID,
let recordOwnerPID = record.ownerPID,
let existingOwnerPath = existing.ownerExecutablePath,
let recordOwnerPath = record.ownerExecutablePath,
let existingOwnerStart = existing.ownerStartEpoch,
let recordOwnerStart = record.ownerStartEpoch
{
return existingOwnerPID == recordOwnerPID &&
existingOwnerPath == recordOwnerPath &&
abs(existingOwnerStart - recordOwnerStart) < 0.001
}
return existing.pid == record.pid &&
existing.executablePath == record.executablePath &&
abs(existing.startEpoch - record.startEpoch) < 0.001
}
self.records.append(record)
self.saves += 1
self.lock.unlock()
}
func remove(_ record: AntigravityCLISessionRecord) throws {
self.lock.lock()
self.records.removeAll {
$0.pid == record.pid &&
$0.executablePath == record.executablePath &&
abs($0.startEpoch - record.startEpoch) < 0.001
}
self.removes += 1
self.lock.unlock()
}
func snapshot() -> AntigravityCLISessionRecord? {
self.lock.lock()
let value = self.records.first
self.lock.unlock()
return value
}
func snapshots() -> [AntigravityCLISessionRecord] {
self.lock.lock()
let value = self.records
self.lock.unlock()
return value
}
var saveCount: Int {
self.lock.lock()
let value = self.saves
self.lock.unlock()
return value
}
var removeCount: Int {
self.lock.lock()
let value = self.removes
self.lock.unlock()
return value
}
}
private final class MemoryAntigravitySessionLaunchLock: AntigravityCLISessionLaunchLocking, @unchecked Sendable {
private let lock = NSLock()
func withLock<T>(_ operation: () throws -> T) throws -> T {
self.lock.lock()
defer { self.lock.unlock() }
return try operation()
}
}
private struct FailingAntigravitySessionLaunchLock: AntigravityCLISessionLaunchLocking {
func withLock<T>(_: () throws -> T) throws -> T {
throw CocoaError(.fileWriteNoPermission)
}
}
private final class AntigravitySessionTerminationRecorder: @unchecked Sendable {
private let lock = NSLock()
private var events: [(pid: pid_t, group: pid_t?, signal: Int32, descendants: [pid_t])] = []
func append(pid: pid_t, group: pid_t?, signal: Int32, descendants: [pid_t]) {
self.lock.lock()
self.events.append((pid: pid, group: group, signal: signal, descendants: descendants))
self.lock.unlock()
}
func snapshot() -> [(pid: pid_t, group: pid_t?, signal: Int32, descendants: [pid_t])] {
self.lock.lock()
let value = self.events
self.lock.unlock()
return value
}
}
private final class AntigravityRegistryRecorder: @unchecked Sendable {
private let lock = NSLock()
private var shouldRegister = true
private var registered: [pid_t] = []
private var unregistered: [pid_t] = []
private var groups: [pid_t: pid_t?] = [:]
func setShouldRegister(_ value: Bool) {
self.lock.lock()
self.shouldRegister = value
self.lock.unlock()
}
func register(pid: pid_t, _: String) -> Bool {
self.lock.lock()
defer { self.lock.unlock() }
guard self.shouldRegister else { return false }
self.registered.append(pid)
return true
}
func update(pid: pid_t, group: pid_t?) {
self.lock.lock()
self.groups[pid] = group
self.lock.unlock()
}
func unregister(pid: pid_t) {
self.lock.lock()
self.unregistered.append(pid)
self.lock.unlock()
}
func registeredSnapshot() -> [pid_t] {
self.lock.lock()
let value = self.registered
self.lock.unlock()
return value
}
func unregisteredSnapshot() -> [pid_t] {
self.lock.lock()
let value = self.unregistered
self.lock.unlock()
return value
}
}
private final class AntigravityLaunchReservationRecorder: @unchecked Sendable {
private let lock = NSLock()
private var beginCount = 0
private var endCount = 0
func begin() -> Bool {
self.lock.lock()
self.beginCount += 1
self.lock.unlock()
return true
}
func end() {
self.lock.lock()
self.endCount += 1
self.lock.unlock()
}
func counts() -> (begin: Int, end: Int) {
self.lock.lock()
let counts = (begin: self.beginCount, end: self.endCount)
self.lock.unlock()
return counts
}
}
private final class AntigravityManualSleeper: @unchecked Sendable {
private let lock = NSLock()
private var continuations: [CheckedContinuation<Void, Error>] = []
func sleep(_: UInt64) async throws {
try await withCheckedThrowingContinuation { continuation in
self.lock.lock()
self.continuations.append(continuation)
self.lock.unlock()
}
}
func resumeAll() {
self.lock.lock()
let continuations = self.continuations
self.continuations.removeAll()
self.lock.unlock()
for continuation in continuations {
continuation.resume()
}
}
func waitForSleeps(_ expectedCount: Int) async {
for _ in 0..<200 {
if self.pendingSleepCount >= expectedCount { return }
try? await Task.sleep(nanoseconds: 1_000_000)
}
Issue.record("Timed out waiting for \(expectedCount) sleep continuation(s)")
}
private var pendingSleepCount: Int {
self.lock.lock()
let count = self.continuations.count
self.lock.unlock()
return count
}
}
struct AntigravityCLISessionTests {
@Test
func `reuses alive process for same binary`() async throws {
let fixture = self.makeFixture()
fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100)
let firstPID = try await fixture.session.beginProbe(binary: "/bin/agy")
await fixture.session.finishProbe(success: true, resetAfterFetch: false)
let secondPID = try await fixture.session.beginProbe(binary: "/bin/agy")
await fixture.session.finishProbe(success: true, resetAfterFetch: false)
#expect(firstPID == 10)
#expect(secondPID == 10)
#expect(fixture.launcher.launchedBinarySnapshot() == ["/bin/agy"])
#expect(fixture.launchReservations.counts().begin == 1)
#expect(fixture.launchReservations.counts().end == 1)
}
@Test
func `relaunches when binary changes`() async throws {
let fixture = self.makeFixture()
fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100)
fixture.identity.setIdentity(pid: 11, executablePath: "/new/agy", startEpoch: 101)
_ = try await fixture.session.beginProbe(binary: "/bin/agy")
await fixture.session.finishProbe(success: true, resetAfterFetch: false)
let secondPID = try await fixture.session.beginProbe(binary: "/new/agy")
await fixture.session.finishProbe(success: true, resetAfterFetch: false)
#expect(secondPID == 11)
#expect(fixture.launcher.launchedBinarySnapshot() == ["/bin/agy", "/new/agy"])
#expect(fixture.registry.unregisteredSnapshot() == [10])
}
@Test
func `replacement launch waits for in progress teardown`() async throws {
let fixture = self.makeFixture(
manualSleep: true,
terminationGracePeriod: 1,
terminateRootStopsProcess: false)
fixture.identity.setIdentity(pid: 10, executablePath: "/old/agy", startEpoch: 100)
fixture.identity.setIdentity(pid: 11, executablePath: "/new/agy", startEpoch: 101)
_ = try await fixture.session.beginProbe(binary: "/old/agy")
await fixture.session.finishProbe(success: true, resetAfterFetch: false)
let firstReplacement = Task {
try await fixture.session.beginProbe(binary: "/new/agy")
}
// Two sleeps register here: the lingering idle-timer sleep (armed by the prior finishProbe;
// the fake sleeper does not honor cancellation) and the teardown grace-period sleep. Wait for
// both before resuming — waiting for only one lets resumeAll() fire before the grace sleep
// parks, stranding it so teardown never completes and the suite hangs to the 120s timeout.
await fixture.sleeper?.waitForSleeps(2)
let secondReplacement = Task {
try await fixture.session.beginProbe(binary: "/new/agy")
}
await Task.yield()
#expect(fixture.launcher.launchedBinarySnapshot() == ["/old/agy"])
fixture.launcher.handleSnapshot().first?.killRoot()
fixture.sleeper?.resumeAll()
let firstPID = try await firstReplacement.value
let secondPID = try await secondReplacement.value
await fixture.session.finishProbe(success: true, resetAfterFetch: false)
await fixture.session.finishProbe(success: true, resetAfterFetch: false)
#expect(firstPID == 11)
#expect(secondPID == 11)
#expect(fixture.launcher.launchedBinarySnapshot() == ["/old/agy", "/new/agy"])
}
@Test
func `replacement waits for active probe before relaunching`() async throws {
let fixture = self.makeFixture()
fixture.identity.setIdentity(pid: 10, executablePath: "/old/agy", startEpoch: 100)
fixture.identity.setIdentity(pid: 11, executablePath: "/new/agy", startEpoch: 101)
let firstPID = try await fixture.session.beginProbe(binary: "/old/agy")
let replacement = Task {
try await fixture.session.beginProbe(binary: "/new/agy")
}
await Task.yield()
await Task.yield()
#expect(firstPID == 10)
#expect(fixture.launcher.launchedBinarySnapshot() == ["/old/agy"])
#expect(fixture.registry.unregisteredSnapshot().isEmpty)
await fixture.session.finishProbe(success: true, resetAfterFetch: false)
let secondPID = try await replacement.value
await fixture.session.finishProbe(success: true, resetAfterFetch: false)
#expect(secondPID == 11)
#expect(fixture.launcher.launchedBinarySnapshot() == ["/old/agy", "/new/agy"])
#expect(fixture.registry.unregisteredSnapshot() == [10])
}
@Test
func `queued replacement hard stops a signed out process`() async throws {
let fixture = self.makeFixture()
fixture.identity.setIdentity(pid: 10, executablePath: "/old/agy", startEpoch: 100)
fixture.identity.setIdentity(pid: 11, executablePath: "/new/agy", startEpoch: 101)
_ = try await fixture.session.beginProbe(binary: "/old/agy")
let replacement = Task {
try await fixture.session.beginProbe(binary: "/new/agy")
}
for _ in 0..<100 where await fixture.session.activeProbeCountForTesting < 2 {
await Task.yield()
}
#expect(await fixture.session.activeProbeCountForTesting == 2)
await fixture.session.finishProbe(success: false, resetAfterFetch: true, forceTerminate: true)
let replacementPID = try await replacement.value
let oldEvents = try #require(fixture.launcher.handleSnapshot().first).snapshotEvents()
#expect(await fixture.session.lastStopReasonForTesting == "authentication required")
await fixture.session.finishProbe(success: true, resetAfterFetch: true)
#expect(replacementPID == 11)
#expect(!oldEvents.contains("sendExit"))
#expect(oldEvents.contains("terminateRoot"))
}
@Test
func `replacement ignores queued starters while waiting for active probe`() async throws {
let fixture = self.makeFixture()
fixture.identity.setIdentity(pid: 10, executablePath: "/old/agy", startEpoch: 100)
fixture.identity.setIdentity(pid: 11, executablePath: "/new/agy", startEpoch: 101)
_ = try await fixture.session.beginProbe(binary: "/old/agy")
let firstReplacement = Task {
try await fixture.session.beginProbe(binary: "/new/agy")
}
let secondReplacement = Task {
try await fixture.session.beginProbe(binary: "/new/agy")
}
await Task.yield()
await Task.yield()
#expect(fixture.launcher.launchedBinarySnapshot() == ["/old/agy"])
await fixture.session.finishProbe(success: true, resetAfterFetch: false)
let didLaunchReplacement = await self.waitForLaunches(fixture.launcher, count: 2)
#expect(didLaunchReplacement)
if !didLaunchReplacement {
await fixture.session.finishProbe(success: true, resetAfterFetch: false)
}
let firstPID = try await firstReplacement.value
let secondPID = try await secondReplacement.value
await fixture.session.finishProbe(success: true, resetAfterFetch: false)
await fixture.session.finishProbe(success: true, resetAfterFetch: false)
#expect(firstPID == 11)
#expect(secondPID == 11)
#expect(fixture.launcher.launchedBinarySnapshot() == ["/old/agy", "/new/agy"])
}
@Test
func `relaunches when existing process is dead`() async throws {
let fixture = self.makeFixture()
fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100)
fixture.identity.setIdentity(pid: 11, executablePath: "/bin/agy", startEpoch: 101)
_ = try await fixture.session.beginProbe(binary: "/bin/agy")
await fixture.session.finishProbe(success: true, resetAfterFetch: false)
fixture.launcher.handleSnapshot().first?.terminateRoot()
let secondPID = try await fixture.session.beginProbe(binary: "/bin/agy")
await fixture.session.finishProbe(success: true, resetAfterFetch: false)
#expect(secondPID == 11)
#expect(fixture.launcher.launchedBinarySnapshot() == ["/bin/agy", "/bin/agy"])
}
@Test
func `pty launcher creates dedicated process group before returning`() throws {
let launcher = AntigravityPTYProcessLauncher()
let handle = try launcher.launch(binary: "/bin/cat")
defer {
handle.killRoot()
handle.terminateTree(signal: SIGKILL, knownDescendants: [])
handle.closePTY()
}
#expect(handle.processGroup == handle.pid)
#expect(getpgid(handle.pid) == handle.pid)
}
@Test
func `pty launcher resets termination signals for child process`() {
var signals = AntigravityPTYProcessLauncher.defaultSignalsForSpawn()
#expect(sigismember(&signals, SIGINT) == 1)
#expect(sigismember(&signals, SIGTERM) == 1)
#expect(sigismember(&signals, SIGHUP) == 1)
}
@Test
func `pty launcher retries transient text busy spawn errors`() {
var attempts = 0
let result = AntigravityPTYProcessLauncher.spawnWithTextBusyRetry(retryDelay: 0) {
attempts += 1
return attempts < 3 ? ETXTBSY : 0
}
#expect(result == 0)
#expect(attempts == 3)
}
@Test
func `pty launcher does not retry other spawn errors`() {
var attempts = 0
let result = AntigravityPTYProcessLauncher.spawnWithTextBusyRetry(retryDelay: 0) {
attempts += 1
return EACCES
}
#expect(result == EACCES)
#expect(attempts == 1)
}
@Test
func `pty launcher uses home and closes unrelated descriptors`() throws {
let tempDirectory = FileManager.default.temporaryDirectory
.appendingPathComponent("antigravity-spawn-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDirectory) }
let inheritedSourceFD = open("/dev/null", O_RDONLY)
guard inheritedSourceFD >= 0 else {
Issue.record("Failed to open descriptor fixture")
return
}
defer { close(inheritedSourceFD) }
let inheritedFD = fcntl(inheritedSourceFD, F_DUPFD, 200)
guard inheritedFD >= 200 else {
Issue.record("Failed to duplicate descriptor fixture")
return
}
defer { close(inheritedFD) }
let outputURL = tempDirectory.appendingPathComponent("result.txt")
let script = """
pwd > \(outputURL.path)
if [ -e /dev/fd/\(inheritedFD) ] || [ -e /proc/self/fd/\(inheritedFD) ]; then
echo inherited >> \(outputURL.path)
else
echo closed >> \(outputURL.path)
fi
"""
let handle = try AntigravityPTYProcessLauncher().launch(
binary: "/bin/sh",
arguments: ["-c", script])
defer {
handle.killRoot()
handle.terminateTree(signal: SIGKILL, knownDescendants: [])
handle.closePTY()
}
for _ in 0..<200 {
if FileManager.default.fileExists(atPath: outputURL.path),
let output = try? String(contentsOf: outputURL, encoding: .utf8)
{
let lines = output
.split(separator: "\n")
.map(String.init)
if lines.count >= 2, output.hasSuffix("\n") { break }
}
Thread.sleep(forTimeInterval: 0.01)
}
let lines = try String(contentsOf: outputURL, encoding: .utf8)
.split(separator: "\n")
.map(String.init)
#expect(lines == [NSHomeDirectory(), "closed"])
}
@Test
func `spawned PTY drain is bounded per call`() throws {
let temp = FileManager.default.temporaryDirectory
.appendingPathComponent("antigravity-drain-\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: temp) }
try Data(repeating: 1, count: 8192 * 65).write(to: temp)
let primaryFD = open(temp.path, O_RDONLY)
guard primaryFD >= 0 else {
Issue.record("Failed to open temporary drain input")
return
}
let secondaryFD = open("/dev/null", O_RDONLY)
guard secondaryFD >= 0 else {
close(primaryFD)
Issue.record("Failed to open /dev/null")
return
}
let handle = AntigravitySpawnedPTYProcessHandle(
pid: getpid(),
processGroup: getpgrp(),
primaryFD: primaryFD,
primaryHandle: FileHandle(fileDescriptor: primaryFD, closeOnDealloc: true),
secondaryHandle: FileHandle(fileDescriptor: secondaryFD, closeOnDealloc: true))
defer { handle.closePTY() }
let output = handle.drainOutput()
#expect(lseek(primaryFD, 0, SEEK_CUR) == off_t(8192 * 64))
#expect(output.count == 8192 * 64)
}
@Test
func `session keeps one rolling PTY buffer across concurrent probes`() async throws {
let fixture = self.makeFixture()
fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100)
_ = try await fixture.session.beginProbe(binary: "/bin/agy")
_ = try await fixture.session.beginProbe(binary: "/bin/agy")
let handle = try #require(fixture.launcher.handleSnapshot().first)
handle.enqueueDrainOutput(Data([0xE2, 0x96]))
let first = await fixture.session.drainOutput()
handle.enqueueDrainOutput(Data([0x84]) + Data("Select login method:".utf8))
let second = await fixture.session.drainOutput()
let third = await fixture.session.drainOutput()
#expect(first == Data([0xE2, 0x96]))
#expect(AntigravityCLIHTTPSFetchStrategy.containsAuthenticationPrompt(second))
#expect(third == second)
await fixture.session.finishProbe(success: false, resetAfterFetch: true, forceTerminate: true)
await fixture.session.finishProbe(success: false, resetAfterFetch: false)
}
@Test
func `authentication prompt matcher tolerates prompt casing and spacing`() {
#expect(AntigravityCLIHTTPSFetchStrategy.containsAuthenticationPrompt(
Data("select LOGIN\nmethod :".utf8)))
#expect(AntigravityCLIHTTPSFetchStrategy.containsAuthenticationPrompt(
Data("Select login method:".utf8)))
#expect(!AntigravityCLIHTTPSFetchStrategy.containsAuthenticationPrompt(
Data("You are currently not signed in".utf8)))
}
@Test
func `session returns complete new output before retaining only its tail`() async throws {
let fixture = self.makeFixture()
fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100)
_ = try await fixture.session.beginProbe(binary: "/bin/agy")
let handle = try #require(fixture.launcher.handleSnapshot().first)
let prompt = Data("Select login method:".utf8)
let oversizedRedraw = prompt + Data(repeating: 0x20, count: 8192)
handle.enqueueDrainOutput(oversizedRedraw)
let searchableOutput = await fixture.session.drainOutput()
let retainedTail = await fixture.session.drainOutput()
#expect(searchableOutput == oversizedRedraw)
#expect(AntigravityCLIHTTPSFetchStrategy.containsAuthenticationPrompt(searchableOutput))
#expect(AntigravityCLIHTTPSFetchStrategy.containsAuthenticationPrompt(retainedTail))
await fixture.session.finishProbe(success: false, resetAfterFetch: true, forceTerminate: true)
}
@Test
func `registration failure tears down launched process`() async {
let fixture = self.makeFixture()
fixture.registry.setShouldRegister(false)
await #expect(throws: AntigravityCLISession.SessionError.self) {
_ = try await fixture.session.beginProbe(binary: "/bin/agy")
}
let handle = fixture.launcher.handleSnapshot().first
#expect(handle?.isRunning == false)
#expect(handle?.snapshotEvents().contains("sendExit") == false)
#expect(handle?.snapshotEvents().contains("closePTY") == true)
#expect(handle?.snapshotEvents().contains("terminateRoot") == true)
#expect(fixture.registry.registeredSnapshot().isEmpty)
}
@Test
func `launch remains usable when coordination lock is unavailable`() async throws {
let fixture = self.makeFixture(launchLock: FailingAntigravitySessionLaunchLock())
fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100)
let pid = try await fixture.session.beginProbe(binary: "/bin/agy")
await fixture.session.finishProbe(success: true, resetAfterFetch: true)
#expect(pid == 10)
#expect(fixture.store.snapshot() == nil)
}
@Test
func `launch remains usable when ownership record cannot be saved`() async throws {
let store = MemoryAntigravitySessionRecordStore(failSaves: true)
let fixture = self.makeFixture(store: store)
fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100)
let pid = try await fixture.session.beginProbe(binary: "/bin/agy")
await fixture.session.finishProbe(success: true, resetAfterFetch: true)
#expect(pid == 10)
#expect(store.snapshot() == nil)
}
@Test
func `idle window tears down warm process`() async throws {
let fixture = self.makeFixture(idleWindow: 0.05, manualSleep: true)
fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100)
_ = try await fixture.session.beginProbe(binary: "/bin/agy")
await fixture.session.finishProbe(success: true, resetAfterFetch: false)
await fixture.sleeper?.waitForSleeps(1)
fixture.sleeper?.resumeAll()
await self.waitUntilStopped(fixture.session)
#expect(await fixture.session.isRunning == false)
#expect(fixture.registry.unregisteredSnapshot() == [10])
#expect(fixture.store.snapshot() == nil)
}
@Test
func `host idle window extends the default session lifetime`() async throws {
let fixture = self.makeFixture(idleWindow: 180)
fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100)
_ = try await fixture.session.beginProbe(binary: "/bin/agy", idleWindow: 360)
await fixture.session.finishProbe(success: true, resetAfterFetch: false)
#expect(await fixture.session.idleWindowForTesting == 360)
}
@Test
func `active probe prevents idle teardown until finish`() async throws {
let fixture = self.makeFixture(idleWindow: 0.05, manualSleep: true)
fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100)
_ = try await fixture.session.beginProbe(binary: "/bin/agy")
await fixture.session.finishProbe(success: true, resetAfterFetch: false)
_ = try await fixture.session.beginProbe(binary: "/bin/agy")
await fixture.sleeper?.waitForSleeps(1)
fixture.sleeper?.resumeAll()
await Task.yield()
#expect(await fixture.session.isRunning)
await fixture.session.finishProbe(success: true, resetAfterFetch: false)
await fixture.sleeper?.waitForSleeps(1)
fixture.sleeper?.resumeAll()
await self.waitUntilStopped(fixture.session)
#expect(await fixture.session.isRunning == false)
}
@Test
func `manual reset waits for active probe to finish`() async throws {
let fixture = self.makeFixture()
fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100)
_ = try await fixture.session.beginProbe(binary: "/bin/agy")
await fixture.session.reset()
#expect(await fixture.session.isRunning)
await fixture.session.finishProbe(success: true, resetAfterFetch: false)
#expect(await fixture.session.isRunning == false)
#expect(fixture.registry.unregisteredSnapshot() == [10])
}
@Test
func `reset reaps persisted stale session when no in memory process exists`() async {
let store = MemoryAntigravitySessionRecordStore(record: AntigravityCLISessionRecord(
pid: 777,
requestedBinaryPath: "/bin/agy",
executablePath: "/bin/agy",
startEpoch: 42,
processGroup: 777))
let fixture = self.makeFixture(store: store)
fixture.identity.setIdentity(pid: 777, executablePath: "/bin/agy", startEpoch: 42)
await fixture.session.reset()
let terminations = fixture.terminations.snapshot()
#expect(terminations.map(\.signal) == [SIGTERM, SIGKILL])
#expect(terminations.allSatisfy { $0.pid == 777 && $0.group == 777 })
#expect(fixture.store.snapshot() == nil)
}
@Test
func `reset preserves persisted session owned by another live process`() async {
let store = MemoryAntigravitySessionRecordStore(record: AntigravityCLISessionRecord(
pid: 777,
requestedBinaryPath: "/bin/agy",
executablePath: "/bin/agy",
startEpoch: 42,
processGroup: 777,
ownerPID: 900,
ownerExecutablePath: "/Applications/CodexBar.app/Contents/MacOS/CodexBar",
ownerStartEpoch: 10))
let fixture = self.makeFixture(store: store, currentProcessID: 901)
fixture.identity.setIdentity(pid: 777, executablePath: "/bin/agy", startEpoch: 42)
fixture.identity.setIdentity(
pid: 900,
executablePath: "/Applications/CodexBar.app/Contents/MacOS/CodexBar",
startEpoch: 10)
await fixture.session.reset()
#expect(fixture.terminations.snapshot().isEmpty)
#expect(fixture.store.snapshot() != nil)
}
@Test
func `launch tracks an independent session while another process is live`() async throws {
let protectedRecord = AntigravityCLISessionRecord(
pid: 777,
requestedBinaryPath: "/bin/agy",
executablePath: "/bin/agy",
startEpoch: 42,
processGroup: 777,
ownerPID: 900,
ownerExecutablePath: "/Applications/CodexBar.app/Contents/MacOS/CodexBar",
ownerStartEpoch: 10)
let store = MemoryAntigravitySessionRecordStore(record: protectedRecord)
let fixture = self.makeFixture(store: store, currentProcessID: 901)
fixture.identity.setIdentity(pid: 777, executablePath: "/bin/agy", startEpoch: 42)
fixture.identity.setIdentity(
pid: 900,
executablePath: "/Applications/CodexBar.app/Contents/MacOS/CodexBar",
startEpoch: 10)
fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100)
fixture.identity.setIdentity(pid: 901, executablePath: "/app/codexbar", startEpoch: 20)
_ = try await fixture.session.beginProbe(binary: "/bin/agy")
#expect(fixture.launcher.launchedBinarySnapshot() == ["/bin/agy"])
#expect(fixture.terminations.snapshot().isEmpty)
#expect(fixture.store.saveCount == 1)
#expect(Set(fixture.store.snapshots().map(\.pid)) == [10, 777])
await fixture.session.finishProbe(success: true, resetAfterFetch: true)
#expect(fixture.store.snapshot() == protectedRecord)
}
@Test
func `different binary tracks an independent session while another process is live`() async throws {
let protectedRecord = AntigravityCLISessionRecord(
pid: 777,
requestedBinaryPath: "/bin/agy",
executablePath: "/bin/agy",
startEpoch: 42,
processGroup: 777,
ownerPID: 900,
ownerExecutablePath: "/Applications/CodexBar.app/Contents/MacOS/CodexBar",
ownerStartEpoch: 10)
let store = MemoryAntigravitySessionRecordStore(record: protectedRecord)
let fixture = self.makeFixture(store: store, currentProcessID: 901)
fixture.identity.setIdentity(pid: 777, executablePath: "/bin/agy", startEpoch: 42)
fixture.identity.setIdentity(
pid: 900,
executablePath: "/Applications/CodexBar.app/Contents/MacOS/CodexBar",
startEpoch: 10)
fixture.identity.setIdentity(pid: 10, executablePath: "/new/agy", startEpoch: 100)
fixture.identity.setIdentity(pid: 901, executablePath: "/app/codexbar", startEpoch: 20)
_ = try await fixture.session.beginProbe(binary: "/new/agy")
#expect(fixture.launcher.launchedBinarySnapshot() == ["/new/agy"])
#expect(fixture.store.saveCount == 1)
#expect(Set(fixture.store.snapshots().map(\.pid)) == [10, 777])
await fixture.session.finishProbe(success: true, resetAfterFetch: true)
#expect(fixture.store.snapshot() == protectedRecord)
}
@Test
func `concurrent hosts atomically track independent sessions`() async {
let store = MemoryAntigravitySessionRecordStore()
let launchLock = MemoryAntigravitySessionLaunchLock()
let identity = FakeAntigravityIdentityProvider()
let firstLauncher = FakeAntigravityProcessLauncher(nextPID: 10)
let secondLauncher = FakeAntigravityProcessLauncher(nextPID: 20)
identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100)
identity.setIdentity(pid: 20, executablePath: "/bin/agy", startEpoch: 200)
identity.setIdentity(pid: 900, executablePath: "/app/CodexBar", startEpoch: 1)
identity.setIdentity(pid: 901, executablePath: "/app/codexbar", startEpoch: 2)
let first = self.makeFixture(
launcher: firstLauncher,
identity: identity,
store: store,
launchLock: launchLock,
currentProcessID: 900)
let second = self.makeFixture(
launcher: secondLauncher,
identity: identity,
store: store,
launchLock: launchLock,
currentProcessID: 901)
async let firstStarted = Self.beginPersistentSession(first.session)
async let secondStarted = Self.beginPersistentSession(second.session)
let results = await [firstStarted, secondStarted]
#expect(!results.contains(false))
#expect(firstLauncher.launchedBinarySnapshot().count + secondLauncher.launchedBinarySnapshot().count == 2)
#expect(Set(store.snapshots().map(\.pid)) == [10, 20])
await first.session.reset()
await second.session.reset()
#expect(store.snapshots().isEmpty)
}
}
extension AntigravityCLISessionTests {
@Test
func `warm reuse reaps a crashed peer session`() async throws {
let store = MemoryAntigravitySessionRecordStore()
let launchLock = MemoryAntigravitySessionLaunchLock()
let identity = FakeAntigravityIdentityProvider()
let firstLauncher = FakeAntigravityProcessLauncher(nextPID: 10)
let secondLauncher = FakeAntigravityProcessLauncher(nextPID: 20)
identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100)
identity.setIdentity(pid: 20, executablePath: "/bin/agy", startEpoch: 200)
identity.setIdentity(pid: 900, executablePath: "/app/CodexBar", startEpoch: 1)
identity.setIdentity(pid: 901, executablePath: "/app/codexbar", startEpoch: 2)
let first = self.makeFixture(
launcher: firstLauncher,
identity: identity,
store: store,
launchLock: launchLock,
currentProcessID: 900)
let second = self.makeFixture(
launcher: secondLauncher,
identity: identity,
store: store,
launchLock: launchLock,
currentProcessID: 901)
#expect(await Self.beginPersistentSession(first.session))
#expect(await Self.beginPersistentSession(second.session))
identity.removeIdentity(pid: 901)
_ = try await first.session.beginProbe(binary: "/bin/agy")
await first.session.finishProbe(success: true, resetAfterFetch: false)
#expect(first.terminations.snapshot().map(\.pid) == [20, 20])
#expect(store.snapshots().map(\.pid) == [10])
await first.session.reset()
await second.session.reset()
}
@Test
func `file store migrates legacy record and preserves independent owners`() throws {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("CodexBarAntigravitySessionTests-\(UUID().uuidString)", isDirectory: true)
let fileURL = directory.appendingPathComponent("agy-session.json")
defer { try? FileManager.default.removeItem(at: directory) }
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let legacy = AntigravityCLISessionRecord(
pid: 10,
requestedBinaryPath: "/bin/agy",
executablePath: "/bin/agy",
startEpoch: 100,
processGroup: 10,
ownerPID: 900,
ownerExecutablePath: "/app/CodexBar",
ownerStartEpoch: 1)
try JSONEncoder().encode(legacy).write(to: fileURL)
let store = AntigravityFileCLISessionRecordStore(fileURL: fileURL)
#expect(try store.load() == [legacy])
let second = AntigravityCLISessionRecord(
pid: 20,
requestedBinaryPath: "/bin/agy",
executablePath: "/bin/agy",
startEpoch: 200,
processGroup: 20,
ownerPID: 901,
ownerExecutablePath: "/app/codexbar",
ownerStartEpoch: 2)
try store.save(second)
#expect(try Set(store.load().map(\.pid)) == [10, 20])
try store.remove(legacy)
#expect(try store.load() == [second])
try Data("{".utf8).write(to: fileURL)
try store.save(legacy)
#expect(try store.load() == [legacy])
}
@Test
func `session reaps stale owner before launch`() async throws {
let protectedRecord = AntigravityCLISessionRecord(
pid: 777,
requestedBinaryPath: "/bin/agy",
executablePath: "/bin/agy",
startEpoch: 42,
processGroup: 777,
ownerPID: 900,
ownerExecutablePath: "/Applications/CodexBar.app/Contents/MacOS/CodexBar",
ownerStartEpoch: 10)
let store = MemoryAntigravitySessionRecordStore(record: protectedRecord)
let fixture = self.makeFixture(store: store, currentProcessID: 901)
fixture.identity.setIdentity(pid: 777, executablePath: "/bin/agy", startEpoch: 42)
fixture.identity.setIdentity(
pid: 900,
executablePath: "/Applications/CodexBar.app/Contents/MacOS/CodexBar",
startEpoch: 10)
fixture.identity.setIdentity(
pid: 901,
executablePath: "/Applications/CodexBar.app/Contents/MacOS/CodexBar",
startEpoch: 20)
fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100)
fixture.identity.removeIdentity(pid: 900)
let secondPID = try await fixture.session.beginProbe(binary: "/bin/agy")
let record = fixture.store.snapshot()
await fixture.session.finishProbe(success: true, resetAfterFetch: true)
#expect(secondPID == 10)
#expect(fixture.launcher.launchedBinarySnapshot() == ["/bin/agy"])
#expect(fixture.terminations.snapshot().map(\.pid) == [777, 777])
#expect(fixture.store.saveCount == 1)
#expect(record?.pid == 10)
#expect(record?.ownerPID == 901)
}
@Test
func `reset rechecks protected persisted session after owner exits`() async {
let store = MemoryAntigravitySessionRecordStore(record: AntigravityCLISessionRecord(
pid: 777,
requestedBinaryPath: "/bin/agy",
executablePath: "/bin/agy",
startEpoch: 42,
processGroup: 777,
ownerPID: 900,
ownerExecutablePath: "/Applications/CodexBar.app/Contents/MacOS/CodexBar",
ownerStartEpoch: 10))
let fixture = self.makeFixture(store: store, currentProcessID: 901)
fixture.identity.setIdentity(pid: 777, executablePath: "/bin/agy", startEpoch: 42)
fixture.identity.setIdentity(
pid: 900,
executablePath: "/Applications/CodexBar.app/Contents/MacOS/CodexBar",
startEpoch: 10)
await fixture.session.reset()
fixture.identity.removeIdentity(pid: 900)
await fixture.session.reset()
let terminations = fixture.terminations.snapshot()
#expect(terminations.map(\.signal) == [SIGTERM, SIGKILL])
#expect(terminations.allSatisfy { $0.pid == 777 && $0.group == 777 })
#expect(fixture.store.snapshot() == nil)
}
@Test
func `teardown preserves record written by another live session`() async throws {
let fixture = self.makeFixture()
fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100)
_ = try await fixture.session.beginProbe(binary: "/bin/agy")
let otherRecord = AntigravityCLISessionRecord(
pid: 777,
requestedBinaryPath: "/other/agy",
executablePath: "/other/agy",
startEpoch: 42,
processGroup: 777)
try fixture.store.save(otherRecord)
await fixture.session.finishProbe(success: true, resetAfterFetch: true)
#expect(fixture.store.snapshot() == otherRecord)
}
@Test
func `force killed process is polled again so the child can be reaped`() async throws {
let fixture = self.makeFixture(terminateRootStopsProcess: false)
fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100)
fixture.identity.setIdentity(pid: 900, executablePath: "/app/CodexBar", startEpoch: 1)
_ = try await fixture.session.beginProbe(binary: "/bin/agy")
await fixture.session.finishProbe(success: true, resetAfterFetch: true)
let events = fixture.launcher.handleSnapshot().first?.snapshotEvents() ?? []
guard let killIndex = events.firstIndex(of: "terminateTree:\(SIGKILL)") else {
Issue.record("Expected SIGKILL during teardown")
return
}
#expect(events.dropFirst(killIndex + 1).contains("isRunning:false"))
}
@Test
func `one shot CLI reset tears down after fetch`() async throws {
let fixture = self.makeFixture()
fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100)
_ = try await fixture.session.beginProbe(binary: "/bin/agy")
await fixture.session.finishProbe(success: true, resetAfterFetch: true)
#expect(await fixture.session.isRunning == false)
#expect(fixture.registry.unregisteredSnapshot() == [10])
}
@Test
func `one shot CLI reset is deferred until all active probes finish`() async throws {
let fixture = self.makeFixture()
fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100)
_ = try await fixture.session.beginProbe(binary: "/bin/agy")
_ = try await fixture.session.beginProbe(binary: "/bin/agy")
await fixture.session.finishProbe(success: true, resetAfterFetch: true)
#expect(await fixture.session.isRunning)
await fixture.session.finishProbe(success: true, resetAfterFetch: false)
#expect(await fixture.session.isRunning == false)
#expect(fixture.registry.unregisteredSnapshot() == [10])
}
@Test
func `authentication reset never writes interactive exit input`() async throws {
let fixture = self.makeFixture()
fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100)
_ = try await fixture.session.beginProbe(binary: "/bin/agy")
await fixture.session.finishProbe(success: false, resetAfterFetch: true, forceTerminate: true)
let events = try #require(fixture.launcher.handleSnapshot().first).snapshotEvents()
#expect(!events.contains("sendExit"))
#expect(events.contains("closePTY"))
#expect(events.contains("terminateRoot"))
#expect(await fixture.session.isRunning == false)
}
@Test
func `failed reset never writes interactive exit input`() async throws {
let fixture = self.makeFixture()
fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100)
_ = try await fixture.session.beginProbe(binary: "/bin/agy")
await fixture.session.finishProbe(success: false, resetAfterFetch: true)
let events = try #require(fixture.launcher.handleSnapshot().first).snapshotEvents()
#expect(!events.contains("sendExit"))
#expect(events.contains("terminateRoot"))
#expect(await fixture.session.isRunning == false)
}
@Test
func `concurrent success preserves a failed probes deferred hard reset`() async throws {
let fixture = self.makeFixture()
fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100)
_ = try await fixture.session.beginProbe(binary: "/bin/agy")
_ = try await fixture.session.beginProbe(binary: "/bin/agy")
await fixture.session.finishProbe(success: false, resetAfterFetch: true)
#expect(await fixture.session.isRunning)
await fixture.session.finishProbe(success: true, resetAfterFetch: false)
let events = try #require(fixture.launcher.handleSnapshot().first).snapshotEvents()
#expect(!events.contains("sendExit"))
#expect(events.contains("terminateRoot"))
#expect(await fixture.session.isRunning == false)
}
@Test
func `idle timeout hard stops a previously failed process`() async throws {
let fixture = self.makeFixture(idleWindow: 0.05, manualSleep: true)
fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100)
_ = try await fixture.session.beginProbe(binary: "/bin/agy")
await fixture.session.finishProbe(success: false, resetAfterFetch: false)
await fixture.sleeper?.waitForSleeps(1)
fixture.sleeper?.resumeAll()
await self.waitUntilStopped(fixture.session)
let events = try #require(fixture.launcher.handleSnapshot().first).snapshotEvents()
#expect(!events.contains("sendExit"))
#expect(events.contains("terminateRoot"))
}
@Test
func `repeated probe failures relaunch session`() async throws {
let fixture = self.makeFixture(failureRelaunchThreshold: 2)
fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100)
fixture.identity.setIdentity(pid: 11, executablePath: "/bin/agy", startEpoch: 101)
_ = try await fixture.session.beginProbe(binary: "/bin/agy")
await fixture.session.finishProbe(success: false, resetAfterFetch: false)
_ = try await fixture.session.beginProbe(binary: "/bin/agy")
await fixture.session.finishProbe(success: false, resetAfterFetch: false)
let relaunchedPID = try await fixture.session.beginProbe(binary: "/bin/agy")
await fixture.session.finishProbe(success: true, resetAfterFetch: false)
#expect(relaunchedPID == 11)
#expect(fixture.launcher.launchedBinarySnapshot() == ["/bin/agy", "/bin/agy"])
#expect(await fixture.session.failureCountForTesting == 0)
}
@Test
func `session reset reasons distinguish authentication from unhealthy probes`() {
#expect(AntigravityCLISession.resetCause(
authenticationRequired: true,
resetAfterFetch: true,
shouldForceStopUnhealthy: true).message == "authentication required")
#expect(AntigravityCLISession.resetCause(
authenticationRequired: false,
resetAfterFetch: true,
shouldForceStopUnhealthy: true).message == "unhealthy CLI HTTPS session")
#expect(AntigravityCLISession.resetCause(
authenticationRequired: false,
resetAfterFetch: true,
shouldForceStopUnhealthy: false).message == "one-shot CLI fetch")
#expect(AntigravityCLISession.resetCause(
authenticationRequired: false,
resetAfterFetch: false,
shouldForceStopUnhealthy: false).message == "deferred reset")
}
@Test
func `deferred unhealthy reset preserves its cause after a concurrent success`() async throws {
let fixture = self.makeFixture(failureRelaunchThreshold: 1)
fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100)
_ = try await fixture.session.beginProbe(binary: "/bin/agy")
_ = try await fixture.session.beginProbe(binary: "/bin/agy")
await fixture.session.finishProbe(success: false, resetAfterFetch: false)
await fixture.session.finishProbe(success: true, resetAfterFetch: false)
#expect(await fixture.session.lastStopReasonForTesting == "unhealthy CLI HTTPS session")
}
@Test
func `deferred authentication reset preserves its cause after a concurrent success`() async throws {
let fixture = self.makeFixture()
fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100)
_ = try await fixture.session.beginProbe(binary: "/bin/agy")
_ = try await fixture.session.beginProbe(binary: "/bin/agy")
await fixture.session.finishProbe(success: false, resetAfterFetch: true, forceTerminate: true)
await fixture.session.finishProbe(success: true, resetAfterFetch: false)
#expect(await fixture.session.lastStopReasonForTesting == "authentication required")
}
@Test
func `success resets failure counter`() async throws {
let fixture = self.makeFixture(failureRelaunchThreshold: 2)
fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100)
_ = try await fixture.session.beginProbe(binary: "/bin/agy")
await fixture.session.finishProbe(success: false, resetAfterFetch: false)
_ = try await fixture.session.beginProbe(binary: "/bin/agy")
await fixture.session.finishProbe(success: true, resetAfterFetch: false)
_ = try await fixture.session.beginProbe(binary: "/bin/agy")
await fixture.session.finishProbe(success: false, resetAfterFetch: false)
#expect(fixture.launcher.launchedBinarySnapshot() == ["/bin/agy"])
#expect(await fixture.session.failureCountForTesting == 1)
}
@Test
func `matching persisted stale process is reaped when resolved binary changed`() async throws {
let store = MemoryAntigravitySessionRecordStore(record: AntigravityCLISessionRecord(
pid: 777,
requestedBinaryPath: "/old/agy",
executablePath: "/old/agy",
startEpoch: 42,
processGroup: 777))
let fixture = self.makeFixture(store: store)
fixture.identity.setIdentity(pid: 777, executablePath: "/old/agy", startEpoch: 42)
fixture.identity.setIdentity(pid: 10, executablePath: "/new/agy", startEpoch: 100)
_ = try await fixture.session.beginProbe(binary: "/new/agy")
await fixture.session.finishProbe(success: true, resetAfterFetch: false)
let terminations = fixture.terminations.snapshot()
#expect(terminations.map(\.signal) == [SIGTERM, SIGKILL])
#expect(terminations.allSatisfy { $0.pid == 777 && $0.group == 777 })
}
@Test
func `matching persisted stale process is reaped before launch`() async throws {
let store = MemoryAntigravitySessionRecordStore(record: AntigravityCLISessionRecord(
pid: 777,
requestedBinaryPath: "/bin/agy",
executablePath: "/bin/agy",
startEpoch: 42,
processGroup: 777))
let fixture = self.makeFixture(store: store)
fixture.identity.setIdentity(pid: 777, executablePath: "/bin/agy", startEpoch: 42)
fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100)
_ = try await fixture.session.beginProbe(binary: "/bin/agy")
await fixture.session.finishProbe(success: true, resetAfterFetch: false)
let terminations = fixture.terminations.snapshot()
#expect(terminations.map(\.signal) == [SIGTERM, SIGKILL])
#expect(terminations.allSatisfy { $0.pid == 777 && $0.group == 777 })
}
@Test
func `non matching persisted process is not reaped`() async throws {
let store = MemoryAntigravitySessionRecordStore(record: AntigravityCLISessionRecord(
pid: 777,
requestedBinaryPath: "/bin/agy",
executablePath: "/bin/agy",
startEpoch: 42,
processGroup: 777))
let fixture = self.makeFixture(store: store)
fixture.identity.setIdentity(pid: 777, executablePath: "/usr/bin/other", startEpoch: 42)
fixture.identity.setIdentity(pid: 10, executablePath: "/bin/agy", startEpoch: 100)
_ = try await fixture.session.beginProbe(binary: "/bin/agy")
await fixture.session.finishProbe(success: true, resetAfterFetch: false)
#expect(fixture.terminations.snapshot().isEmpty)
}
private func waitForLaunches(_ launcher: FakeAntigravityProcessLauncher, count: Int) async -> Bool {
for _ in 0..<200 {
if launcher.launchedBinarySnapshot().count >= count { return true }
try? await Task.sleep(nanoseconds: 1_000_000)
}
return false
}
private func waitUntilStopped(_ session: AntigravityCLISession) async {
for _ in 0..<200 {
let running = await session.isRunning
if !running { return }
await Task.yield()
}
Issue.record("Timed out waiting for Antigravity CLI session to stop")
}
private static func beginPersistentSession(_ session: AntigravityCLISession) async -> Bool {
do {
_ = try await session.beginProbe(binary: "/bin/agy")
await session.finishProbe(success: true, resetAfterFetch: false)
return true
} catch {
return false
}
}
private struct Fixture {
let session: AntigravityCLISession
let launcher: FakeAntigravityProcessLauncher
let identity: FakeAntigravityIdentityProvider
let store: MemoryAntigravitySessionRecordStore
let terminations: AntigravitySessionTerminationRecorder
let registry: AntigravityRegistryRecorder
let launchReservations: AntigravityLaunchReservationRecorder
let sleeper: AntigravityManualSleeper?
}
private func makeFixture(
launcher suppliedLauncher: FakeAntigravityProcessLauncher? = nil,
identity suppliedIdentity: FakeAntigravityIdentityProvider? = nil,
store: MemoryAntigravitySessionRecordStore = MemoryAntigravitySessionRecordStore(),
launchLock: any AntigravityCLISessionLaunchLocking = MemoryAntigravitySessionLaunchLock(),
idleWindow: TimeInterval = 3600,
failureRelaunchThreshold: Int = 2,
manualSleep: Bool = false,
terminationGracePeriod: TimeInterval = 0,
terminateRootStopsProcess: Bool = true,
currentProcessID: pid_t = 900) -> Fixture
{
let launcher = suppliedLauncher ?? FakeAntigravityProcessLauncher(nextPID: 10)
launcher.setTerminateRootStopsProcess(terminateRootStopsProcess)
let identity = suppliedIdentity ?? FakeAntigravityIdentityProvider()
let terminations = AntigravitySessionTerminationRecorder()
let registry = AntigravityRegistryRecorder()
let launchReservations = AntigravityLaunchReservationRecorder()
let sleeper = manualSleep ? AntigravityManualSleeper() : nil
let session = AntigravityCLISession(dependencies: AntigravityCLISession.Dependencies(
launcher: launcher,
identityProvider: identity,
recordStore: store,
launchLock: launchLock,
beginAppShutdownTrackedLaunch: { launchReservations.begin() },
endAppShutdownTrackedLaunch: { launchReservations.end() },
registerForAppShutdown: { pid, binary in registry.register(pid: pid, binary) },
updateAppShutdownProcessGroup: { pid, group in registry.update(pid: pid, group: group) },
unregisterForAppShutdown: { pid in registry.unregister(pid: pid) },
descendantPIDs: { pid in [pid + 1, pid + 2] },
terminateProcessTree: { pid, group, signal, descendants in
terminations.append(pid: pid, group: group, signal: signal, descendants: descendants)
},
currentProcessID: { currentProcessID },
now: Date.init,
sleep: { nanoseconds in
if let sleeper {
try await sleeper.sleep(nanoseconds)
} else {
try await Task.sleep(nanoseconds: nanoseconds)
}
},
idleWindow: idleWindow,
failureRelaunchThreshold: failureRelaunchThreshold,
terminationGracePeriod: terminationGracePeriod))
return Fixture(
session: session,
launcher: launcher,
identity: identity,
store: store,
terminations: terminations,
registry: registry,
launchReservations: launchReservations,
sleeper: sleeper)
}
}