From dbe477e277ce7f4625e34a4426710a83be869e8f Mon Sep 17 00:00:00 2001 From: Alexander Hoffer Date: Tue, 14 Jul 2026 11:27:29 +0200 Subject: [PATCH] Pause blocked Claude CLI auth probes --- .../Claude/ClaudeCLIAuthPreflightGate.swift | 176 ++++++++++++++++++ .../Claude/ClaudeCLIAuthStatusProbe.swift | 91 ++++++++- .../Claude/ClaudeProviderDescriptor.swift | 36 +++- .../ClaudeBaselineCharacterizationTests.swift | 175 +++++++++++------ .../ClaudeCLIAuthPreflightGateTests.swift | 69 +++++++ .../ClaudeCLIAuthStatusProbeTests.swift | 64 +++++++ 6 files changed, 544 insertions(+), 67 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/Claude/ClaudeCLIAuthPreflightGate.swift create mode 100644 Tests/CodexBarTests/ClaudeCLIAuthPreflightGateTests.swift diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeCLIAuthPreflightGate.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeCLIAuthPreflightGate.swift new file mode 100644 index 0000000000..ea2fff9fac --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeCLIAuthPreflightGate.swift @@ -0,0 +1,176 @@ +import Foundation + +#if os(macOS) +import os.lock + +enum ClaudeCLIAuthPreflightGate { + private struct State { + var loaded = false + var blockedUntil: Date? + } + + private static let lock = OSAllocatedUnfairLock(initialState: State()) + private static let blockedUntilKey = "claudeCLIAuthPreflightBlockedUntilV1" + private static let timeoutCooldown: TimeInterval = 60 * 15 + private static let failureCooldown: TimeInterval = 60 * 15 + private static let log = CodexBarLog.logger(LogCategories.claudeCLI) + + #if DEBUG + final class BlockedUntilStore: @unchecked Sendable { + var blockedUntil: Date? + + init(blockedUntil: Date? = nil) { + self.blockedUntil = blockedUntil + } + } + + @TaskLocal private static var taskStoreOverrideForTesting: BlockedUntilStore? + #endif + + static func blockedUntil( + interaction: ProviderInteraction = ProviderInteractionContext.current, + now: Date = Date()) -> Date? + { + guard interaction != .userInitiated else { return nil } + #if DEBUG + if let store = self.taskStoreOverrideForTesting { + return self.activeBlockedUntil(store.blockedUntil, now: now) { store.blockedUntil = $0 } + } + #endif + return self.lock.withLock { state in + self.loadIfNeeded(&state) + return self.activeBlockedUntil(state.blockedUntil, now: now) { + state.blockedUntil = $0 + self.persist(state) + } + } + } + + static func recordTimeout(now: Date = Date()) { + self.recordBlocked(until: now.addingTimeInterval(self.timeoutCooldown), reason: "timeout") + } + + static func recordFailure(now: Date = Date()) { + self.recordBlocked(until: now.addingTimeInterval(self.failureCooldown), reason: "failure") + } + + @discardableResult + static func clear(now: Date = Date()) -> Bool { + #if DEBUG + if let store = self.taskStoreOverrideForTesting { + let wasBlocked = store.blockedUntil.map { $0 > now } ?? false + store.blockedUntil = nil + return wasBlocked + } + #endif + return self.lock.withLock { state in + self.loadIfNeeded(&state) + let wasBlocked = state.blockedUntil.map { $0 > now } ?? false + guard state.blockedUntil != nil else { return false } + state.blockedUntil = nil + self.persist(state) + return wasBlocked + } + } + + #if DEBUG + static func withBlockedUntilStoreOverrideForTesting( + _ store: BlockedUntilStore?, + operation: () async throws -> T) async rethrows -> T + { + try await self.$taskStoreOverrideForTesting.withValue(store) { + try await operation() + } + } + + static func resetForTesting() { + self.lock.withLock { state in + state.loaded = true + state.blockedUntil = nil + UserDefaults.standard.removeObject(forKey: self.blockedUntilKey) + } + } + + static var blockedUntilKeyForTesting: String { + self.blockedUntilKey + } + + static func reloadPersistedStateForTesting() { + self.lock.withLock { state in + state.loaded = false + state.blockedUntil = nil + } + } + #endif + + private static func activeBlockedUntil( + _ blockedUntil: Date?, + now: Date, + update: (Date?) -> Void) -> Date? + { + guard let blockedUntil else { return nil } + guard blockedUntil > now else { + update(nil) + return nil + } + return blockedUntil + } + + private static func recordBlocked(until: Date, reason: String) { + #if DEBUG + if let store = self.taskStoreOverrideForTesting { + store.blockedUntil = until + return + } + #endif + self.lock.withLock { state in + self.loadIfNeeded(&state) + state.blockedUntil = until + self.persist(state) + } + self.log.warning( + "Claude CLI background auth preflight paused", + metadata: [ + "reason": reason, + "until": "\(until.timeIntervalSince1970)", + ]) + } + + private static func loadIfNeeded(_ state: inout State) { + guard !state.loaded else { return } + state.loaded = true + if let raw = UserDefaults.standard.object(forKey: self.blockedUntilKey) as? Double { + state.blockedUntil = Date(timeIntervalSince1970: raw) + } + } + + private static func persist(_ state: State) { + if let blockedUntil = state.blockedUntil { + UserDefaults.standard.set(blockedUntil.timeIntervalSince1970, forKey: self.blockedUntilKey) + } else { + UserDefaults.standard.removeObject(forKey: self.blockedUntilKey) + } + } +} +#else +enum ClaudeCLIAuthPreflightGate { + static func blockedUntil( + interaction _: ProviderInteraction = ProviderInteractionContext.current, + now _: Date = Date()) -> Date? + { + nil + } + + static func recordTimeout(now _: Date = Date()) {} + static func recordFailure(now _: Date = Date()) {} + + @discardableResult + static func clear(now _: Date = Date()) -> Bool { + false + } + + #if DEBUG + static func resetForTesting() {} + #endif +} +#endif diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeCLIAuthStatusProbe.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeCLIAuthStatusProbe.swift index 4f3b6f57b6..b54891f73b 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeCLIAuthStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeCLIAuthStatusProbe.swift @@ -1,14 +1,77 @@ import Foundation enum ClaudeCLIAuthStatusProbe { + enum Outcome: Equatable, Sendable { + case loggedIn + case loggedOut + case timedOut + case cancelled + case failed + } + private struct Response: Decodable { let loggedIn: Bool } - static func isLoggedIn( + private struct Request: Hashable, Sendable { + let binary: String + let environment: [String: String] + let timeout: TimeInterval + } + + private actor Coordinator { + private var inFlight: [Request: Task] = [:] + + func run( + request: Request, + operation: @escaping @Sendable () async -> Outcome) async -> Outcome + { + if let task = self.inFlight[request] { + return await task.value + } + + let task = Task { await operation() } + self.inFlight[request] = task + let outcome = await task.value + self.inFlight[request] = nil + return outcome + } + } + + private static let coordinator = Coordinator() + + #if DEBUG + typealias ProbeOverride = @Sendable (String, [String: String], TimeInterval) async -> Outcome + @TaskLocal static var probeOverrideForTesting: ProbeOverride? + #endif + + static func probe( + binary: String, + environment: [String: String], + timeout: TimeInterval = 5) async -> Outcome + { + guard !Task.isCancelled else { return .cancelled } + let request = Request(binary: binary, environment: environment, timeout: timeout) + #if DEBUG + let override = self.probeOverrideForTesting + #endif + // Keep the one bounded subprocess alive when a waiter is cancelled so other concurrent refreshes can share + // its result. The cancelled waiter still receives `.cancelled` after the shared probe finishes. + let outcome = await self.coordinator.run(request: request) { + #if DEBUG + if let override { + return await override(binary, environment, timeout) + } + #endif + return await self.runProbe(binary: binary, environment: environment, timeout: timeout) + } + return Task.isCancelled ? .cancelled : outcome + } + + private static func runProbe( binary: String, environment: [String: String], - timeout: TimeInterval = 5) async -> Bool + timeout: TimeInterval) async -> Outcome { do { let result = try await SubprocessRunner.run( @@ -18,18 +81,26 @@ enum ClaudeCLIAuthStatusProbe { timeout: timeout, standardInput: FileHandle.nullDevice, label: "claude-auth-status") - return self.parseLoggedIn(result.stdout) + guard let response = self.parseResponse(result.stdout) else { return .failed } + return response.loggedIn ? .loggedIn : .loggedOut + } catch is CancellationError { + return .cancelled + } catch let error as SubprocessRunnerError { + if case .timedOut = error { + return .timedOut + } + return .failed } catch { - return false + return .failed } } static func parseLoggedIn(_ output: String) -> Bool { - guard let data = output.data(using: .utf8), - let response = try? JSONDecoder().decode(Response.self, from: data) - else { - return false - } - return response.loggedIn + self.parseResponse(output)?.loggedIn == true + } + + private static func parseResponse(_ output: String) -> Response? { + guard let data = output.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(Response.self, from: data) } } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index 1868f5c6e3..81faf3ef78 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -633,6 +633,8 @@ public enum ClaudeWebFetchStrategyError: LocalizedError, Equatable, Sendable { } struct ClaudeCLIFetchStrategy: ProviderFetchStrategy { + private static let log = CodexBarLog.logger(LogCategories.claudeCLI) + let id: String = "claude.cli" let kind: ProviderFetchKind = .cli let useWebExtras: Bool @@ -643,13 +645,34 @@ struct ClaudeCLIFetchStrategy: ProviderFetchStrategy { func isAvailable(_ context: ProviderFetchContext) async -> Bool { // The interactive Claude REPL can open browser OAuth when it starts logged out. CLI runtime and background // app Auto refreshes must establish authentication through the non-interactive status command first. - let requiresAuthPreflight = context.runtime == .cli || ( - context.runtime == .app && - context.sourceMode == .auto && - ProviderInteractionContext.current == .background) + let isBackgroundAppAuto = context.runtime == .app && + context.sourceMode == .auto && + ProviderInteractionContext.current == .background + let requiresAuthPreflight = context.runtime == .cli || isBackgroundAppAuto guard requiresAuthPreflight else { return true } + if isBackgroundAppAuto, + let blockedUntil = ClaudeCLIAuthPreflightGate.blockedUntil() + { + Self.log.debug( + "Claude CLI background auth preflight skipped by cooldown", + metadata: ["until": "\(blockedUntil.timeIntervalSince1970)"]) + return false + } guard let binary = ClaudeCLIResolver.resolvedBinaryPath(environment: context.env) else { return false } - return await ClaudeCLIAuthStatusProbe.isLoggedIn(binary: binary, environment: context.env) + let outcome = await ClaudeCLIAuthStatusProbe.probe(binary: binary, environment: context.env) + if isBackgroundAppAuto { + switch outcome { + case .loggedIn: + ClaudeCLIAuthPreflightGate.clear() + case .timedOut: + ClaudeCLIAuthPreflightGate.recordTimeout() + case .failed: + ClaudeCLIAuthPreflightGate.recordFailure() + case .loggedOut, .cancelled: + break + } + } + return outcome == .loggedIn } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { @@ -663,6 +686,9 @@ struct ClaudeCLIFetchStrategy: ProviderFetchStrategy { webOrganizationID: context.settings?.claude?.organizationID, keepCLISessionsAlive: keepAlive) let usage = try await fetcher.loadLatestUsage(model: "sonnet") + if context.runtime == .app { + ClaudeCLIAuthPreflightGate.clear() + } return self.makeResult( usage: ClaudeOAuthFetchStrategy.snapshot(from: usage), sourceLabel: "claude") diff --git a/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift index d63b141fd8..66c010efd2 100644 --- a/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift +++ b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift @@ -150,10 +150,15 @@ struct ClaudeBaselineCharacterizationTests { ] let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) let context = self.makeContext(runtime: .app, sourceMode: .cli, env: env, settings: settings) - let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) + let gateStore = ClaudeCLIAuthPreflightGate.BlockedUntilStore( + blockedUntil: Date().addingTimeInterval(3600)) + + await ClaudeCLIAuthPreflightGate.withBlockedUntilStoreOverrideForTesting(gateStore) { + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) - #expect(strategies.map(\.id) == ["claude.cli"]) - #expect(await strategies[0].isAvailable(context)) + #expect(strategies.map(\.id) == ["claude.cli"]) + #expect(await strategies[0].isAvailable(context)) + } } @Test @@ -200,16 +205,25 @@ struct ClaudeBaselineCharacterizationTests { .appendingPathComponent("claude-invocations-\(UUID().uuidString).log") let stubCLIPath = try self.makeStubClaudeCLI(loggedIn: false, invocationLog: invocationLog) let env = ["CLAUDE_CLI_PATH": stubCLIPath] + let gateStore = ClaudeCLIAuthPreflightGate.BlockedUntilStore() - await self.withNoOAuthCredentials { - let outcome = await self.fetchOutcome(runtime: .app, sourceMode: .auto, env: env, settings: settings) - - #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"]) - #expect(outcome.attempts.map(\.wasAvailable) == [false, false, false]) + await ClaudeCLIAuthPreflightGate.withBlockedUntilStoreOverrideForTesting(gateStore) { + await self.withNoOAuthCredentials { + for _ in 0..<2 { + let outcome = await self.fetchOutcome( + runtime: .app, + sourceMode: .auto, + env: env, + settings: settings) + + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"]) + #expect(outcome.attempts.map(\.wasAvailable) == [false, false, false]) + } + } } let invocations = try String(contentsOf: invocationLog, encoding: .utf8) - #expect(invocations == "auth status --json\n") + #expect(invocations == "auth status --json\nauth status --json\n") } @Test(arguments: ["nonzero", "timeout", "malformed"]) @@ -235,6 +249,7 @@ struct ClaudeBaselineCharacterizationTests { authStatusScript: authStatusScript, invocationLog: invocationLog) let env = ["CLAUDE_CLI_PATH": stubCLIPath] + let gateStore = ClaudeCLIAuthPreflightGate.BlockedUntilStore() let usageLoader: ClaudeWebFetchStrategy.UsageLoader = { _ in ClaudeUsageSnapshot( primary: RateWindow( @@ -251,16 +266,28 @@ struct ClaudeBaselineCharacterizationTests { rawText: nil) } - let outcome = await self.withNoOAuthCredentials { - await ClaudeWebFetchStrategy.$usageLoaderOverrideForTesting.withValue(usageLoader) { - await self.fetchOutcome(runtime: .app, sourceMode: .auto, env: env, settings: settings) + let outcomes = await ClaudeCLIAuthPreflightGate.withBlockedUntilStoreOverrideForTesting(gateStore) { + await self.withNoOAuthCredentials { + await ClaudeWebFetchStrategy.$usageLoaderOverrideForTesting.withValue(usageLoader) { + var outcomes: [ProviderFetchOutcome] = [] + for _ in 0..<2 { + await outcomes.append(self.fetchOutcome( + runtime: .app, + sourceMode: .auto, + env: env, + settings: settings)) + } + return outcomes + } } } - let result = try outcome.result.get() - #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"]) - #expect(outcome.attempts.map(\.wasAvailable) == [false, false, true]) - #expect(result.strategyID == "claude.web") + for outcome in outcomes { + let result = try outcome.result.get() + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"]) + #expect(outcome.attempts.map(\.wasAvailable) == [false, false, true]) + #expect(result.strategyID == "claude.web") + } let invocations = try String(contentsOf: invocationLog, encoding: .utf8) #expect(invocations == "auth status --json\n") } @@ -274,19 +301,60 @@ struct ClaudeBaselineCharacterizationTests { manualCookieHeader: nil)) let invocationLog = FileManager.default.temporaryDirectory .appendingPathComponent("claude-invocations-\(UUID().uuidString).log") - let stubCLIPath = try self.makeStubClaudeCLI(loggedIn: false, invocationLog: invocationLog) + let stubCLIPath = try self.makeStubClaudeCLI(loggedIn: true, invocationLog: invocationLog) let env = ["CLAUDE_CLI_PATH": stubCLIPath] let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) let context = self.makeContext(runtime: .app, sourceMode: .auto, env: env, settings: settings) let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) let cli = try #require(strategies.first { $0.id == "claude.cli" }) + let gateStore = ClaudeCLIAuthPreflightGate.BlockedUntilStore( + blockedUntil: Date().addingTimeInterval(3600)) + + try await ClaudeCLIAuthPreflightGate.withBlockedUntilStoreOverrideForTesting(gateStore) { + let cliAvailable = await ProviderInteractionContext.$current.withValue(.userInitiated) { + await cli.isAvailable(context) + } + + #expect(cliAvailable) + #expect(!FileManager.default.fileExists(atPath: invocationLog.path)) + + _ = try await ProviderInteractionContext.$current.withValue(.userInitiated) { + try await cli.fetch(context) + } + #expect(gateStore.blockedUntil == nil) + + let backgroundAvailable = await ProviderInteractionContext.$current.withValue(.background) { + await cli.isAvailable(context) + } + #expect(backgroundAvailable) + } + } + + @Test + func `CLI runtime ignores app background auth cooldown`() async throws { + let settings = ProviderSettingsSnapshot.make(claude: .init( + usageDataSource: .auto, + webExtrasEnabled: false, + cookieSource: .off, + manualCookieHeader: nil)) + let invocationLog = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-invocations-\(UUID().uuidString).log") + let stubCLIPath = try self.makeStubClaudeCLI(loggedIn: true, invocationLog: invocationLog) + let env = ["CLAUDE_CLI_PATH": stubCLIPath] + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let context = self.makeContext(runtime: .cli, sourceMode: .auto, env: env, settings: settings) + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) + let cli = try #require(strategies.first { $0.id == "claude.cli" }) + let gateStore = ClaudeCLIAuthPreflightGate.BlockedUntilStore( + blockedUntil: Date().addingTimeInterval(3600)) - let cliAvailable = await ProviderInteractionContext.$current.withValue(.userInitiated) { - await cli.isAvailable(context) + await ClaudeCLIAuthPreflightGate.withBlockedUntilStoreOverrideForTesting(gateStore) { + #expect(await cli.isAvailable(context)) + #expect(gateStore.blockedUntil != nil) } - #expect(cliAvailable) - #expect(!FileManager.default.fileExists(atPath: invocationLog.path)) + let invocations = try String(contentsOf: invocationLog, encoding: .utf8) + #expect(invocations == "auth status --json\n") } @Test @@ -332,40 +400,43 @@ struct ClaudeBaselineCharacterizationTests { manualCookieHeader: nil)) let stubCLIPath = try self.makeStubClaudeCLI() let env = ["CLAUDE_CLI_PATH": stubCLIPath] + let gateStore = ClaudeCLIAuthPreflightGate.BlockedUntilStore() - await self.withNoOAuthCredentials { - let fetchOverride: @Sendable (String, TimeInterval, Bool) async throws - -> ClaudeStatusSnapshot = { binary, _, _ in - #expect(binary == stubCLIPath) - return ClaudeStatusSnapshot( - sessionPercentLeft: 88, - weeklyPercentLeft: 60, - opusPercentLeft: 95, - accountEmail: "user@example.com", - accountOrganization: "Example Org", - loginMethod: nil, - primaryResetDescription: "Resets 11am", - secondaryResetDescription: "Resets Nov 21", - opusResetDescription: "Resets Nov 21", - rawText: "stub") + await ClaudeCLIAuthPreflightGate.withBlockedUntilStoreOverrideForTesting(gateStore) { + await self.withNoOAuthCredentials { + let fetchOverride: @Sendable (String, TimeInterval, Bool) async throws + -> ClaudeStatusSnapshot = { binary, _, _ in + #expect(binary == stubCLIPath) + return ClaudeStatusSnapshot( + sessionPercentLeft: 88, + weeklyPercentLeft: 60, + opusPercentLeft: 95, + accountEmail: "user@example.com", + accountOrganization: "Example Org", + loginMethod: nil, + primaryResetDescription: "Resets 11am", + secondaryResetDescription: "Resets Nov 21", + opusResetDescription: "Resets Nov 21", + rawText: "stub") + } + let outcome = await ClaudeStatusProbe.$fetchOverride.withValue(fetchOverride) { + await self.fetchOutcome(runtime: .app, sourceMode: .auto, env: env, settings: settings) } - let outcome = await ClaudeStatusProbe.$fetchOverride.withValue(fetchOverride) { - await self.fetchOutcome(runtime: .app, sourceMode: .auto, env: env, settings: settings) - } - #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli"]) - #expect(outcome.attempts.map(\.wasAvailable) == [false, true]) - - switch outcome.result { - case let .success(result): - #expect(result.strategyID == "claude.cli") - #expect(result.sourceLabel == "claude") - #expect(result.usage.primary?.usedPercent == 12) - #expect(result.usage.secondary?.usedPercent == 40) - #expect(result.usage.tertiary?.usedPercent == 5) - #expect(result.usage.identity?.accountEmail == "user@example.com") - case let .failure(error): - Issue.record("Unexpected failure: \(error)") + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli"]) + #expect(outcome.attempts.map(\.wasAvailable) == [false, true]) + + switch outcome.result { + case let .success(result): + #expect(result.strategyID == "claude.cli") + #expect(result.sourceLabel == "claude") + #expect(result.usage.primary?.usedPercent == 12) + #expect(result.usage.secondary?.usedPercent == 40) + #expect(result.usage.tertiary?.usedPercent == 5) + #expect(result.usage.identity?.accountEmail == "user@example.com") + case let .failure(error): + Issue.record("Unexpected failure: \(error)") + } } } } diff --git a/Tests/CodexBarTests/ClaudeCLIAuthPreflightGateTests.swift b/Tests/CodexBarTests/ClaudeCLIAuthPreflightGateTests.swift new file mode 100644 index 0000000000..5ba01a06ce --- /dev/null +++ b/Tests/CodexBarTests/ClaudeCLIAuthPreflightGateTests.swift @@ -0,0 +1,69 @@ +import Foundation +import Testing +@testable import CodexBarCore + +#if os(macOS) +@Suite(.serialized) +struct ClaudeCLIAuthPreflightGateTests { + @Test + func `ambiguous timeout uses a short cooldown`() async { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let store = ClaudeCLIAuthPreflightGate.BlockedUntilStore() + + await ClaudeCLIAuthPreflightGate.withBlockedUntilStoreOverrideForTesting(store) { + ClaudeCLIAuthPreflightGate.recordTimeout(now: now) + + #expect(ClaudeCLIAuthPreflightGate.blockedUntil(now: now) == now.addingTimeInterval(15 * 60)) + #expect(ClaudeCLIAuthPreflightGate.blockedUntil(now: now.addingTimeInterval(15 * 60)) == nil) + } + } + + @Test + func `generic failure uses a short cooldown`() async { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let store = ClaudeCLIAuthPreflightGate.BlockedUntilStore() + + await ClaudeCLIAuthPreflightGate.withBlockedUntilStoreOverrideForTesting(store) { + ClaudeCLIAuthPreflightGate.recordFailure(now: now) + + #expect(ClaudeCLIAuthPreflightGate.blockedUntil(now: now) == now.addingTimeInterval(15 * 60)) + #expect(ClaudeCLIAuthPreflightGate.blockedUntil(now: now.addingTimeInterval(15 * 60)) == nil) + } + } + + @Test + func `user action bypasses and successful repair clears cooldown`() async { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let store = ClaudeCLIAuthPreflightGate.BlockedUntilStore() + + await ClaudeCLIAuthPreflightGate.withBlockedUntilStoreOverrideForTesting(store) { + ClaudeCLIAuthPreflightGate.recordTimeout(now: now) + + #expect(ClaudeCLIAuthPreflightGate.blockedUntil(interaction: .userInitiated, now: now) == nil) + #expect(ClaudeCLIAuthPreflightGate.clear(now: now)) + #expect(ClaudeCLIAuthPreflightGate.blockedUntil(now: now) == nil) + } + } + + @Test + func `persisted cooldown survives reload and expired state is removed`() { + let key = ClaudeCLIAuthPreflightGate.blockedUntilKeyForTesting + let defaults = UserDefaults.standard + let now = Date(timeIntervalSince1970: 1_800_000_000) + let future = now.addingTimeInterval(15 * 60) + defer { ClaudeCLIAuthPreflightGate.resetForTesting() } + + defaults.set(future.timeIntervalSince1970, forKey: key) + ClaudeCLIAuthPreflightGate.reloadPersistedStateForTesting() + + #expect(ClaudeCLIAuthPreflightGate.blockedUntil(now: now) == future) + #expect(ClaudeCLIAuthPreflightGate.blockedUntil(interaction: .userInitiated, now: now) == nil) + + defaults.set(now.addingTimeInterval(-1).timeIntervalSince1970, forKey: key) + ClaudeCLIAuthPreflightGate.reloadPersistedStateForTesting() + + #expect(ClaudeCLIAuthPreflightGate.blockedUntil(now: now) == nil) + #expect(defaults.object(forKey: key) == nil) + } +} +#endif diff --git a/Tests/CodexBarTests/ClaudeCLIAuthStatusProbeTests.swift b/Tests/CodexBarTests/ClaudeCLIAuthStatusProbeTests.swift index ecdca5fca6..6b463829af 100644 --- a/Tests/CodexBarTests/ClaudeCLIAuthStatusProbeTests.swift +++ b/Tests/CodexBarTests/ClaudeCLIAuthStatusProbeTests.swift @@ -1,6 +1,18 @@ import Testing @testable import CodexBarCore +private actor ClaudeCLIAuthStatusProbeCounter { + private var value = 0 + + func increment() { + self.value += 1 + } + + func count() -> Int { + self.value + } +} + struct ClaudeCLIAuthStatusProbeTests { @Test func `parses logged in status`() { @@ -13,4 +25,56 @@ struct ClaudeCLIAuthStatusProbeTests { #expect(!ClaudeCLIAuthStatusProbe.parseLoggedIn("not-json")) #expect(!ClaudeCLIAuthStatusProbe.parseLoggedIn(#"{"authMethod":"none"}"#)) } + + @Test + func `concurrent identical probes share one subprocess attempt`() async { + typealias Outcome = ClaudeCLIAuthStatusProbe.Outcome + let counter = ClaudeCLIAuthStatusProbeCounter() + let probeOverride: ClaudeCLIAuthStatusProbe.ProbeOverride = { _, _, _ in + await counter.increment() + try? await Task.sleep(for: .milliseconds(50)) + return .timedOut + } + + let outcomes = await ClaudeCLIAuthStatusProbe.$probeOverrideForTesting.withValue(probeOverride) { + await withTaskGroup(of: Outcome.self) { group -> [Outcome] in + for _ in 0..<8 { + group.addTask { + await ClaudeCLIAuthStatusProbe.probe( + binary: "/synthetic/claude", + environment: ["HOME": "/synthetic/home"]) + } + } + return await group.reduce(into: []) { $0.append($1) } + } + } + + #expect(outcomes.count == 8) + #expect(outcomes.allSatisfy { $0 == .timedOut }) + #expect(await counter.count() == 1) + } + + @Test + func `cancelled waiter reports cancellation without cancelling shared probe`() async { + let counter = ClaudeCLIAuthStatusProbeCounter() + let probeOverride: ClaudeCLIAuthStatusProbe.ProbeOverride = { _, _, _ in + await counter.increment() + try? await Task.sleep(for: .milliseconds(100)) + return .loggedIn + } + let task = Task { + await ClaudeCLIAuthStatusProbe.$probeOverrideForTesting.withValue(probeOverride) { + await ClaudeCLIAuthStatusProbe.probe( + binary: "/synthetic/cancelled-claude", + environment: ["HOME": "/synthetic/cancelled-home"]) + } + } + + while await counter.count() == 0 { + await Task.yield() + } + task.cancel() + + #expect(await task.value == .cancelled) + } }