From 6cedb81126f02d1b5bf85023fa938dbd41abe64c Mon Sep 17 00:00:00 2001 From: rohitjavvadi Date: Mon, 18 May 2026 21:55:52 +0530 Subject: [PATCH 1/2] Fix Claude usage loading stalls --- .../Providers/Claude/ClaudeStatusProbe.swift | 15 +++++++++++ .../Providers/Claude/ClaudeUsageFetcher.swift | 3 ++- .../ClaudeCLITimeoutRetryTests.swift | 26 +++++++++++++++++++ Tests/CodexBarTests/StatusProbeTests.swift | 5 ++-- 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift index 8188b1165..3e964ccec 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift @@ -158,6 +158,15 @@ public struct ClaudeStatusProbe: Sendable { throw ClaudeStatusProbeError.parseFailed(usageError) } + if self.isUsageStillLoading(text: clean) { + Self.dumpIfNeeded( + enabled: shouldDump, + reason: "usage still loading", + usage: clean, + status: statusText) + throw ClaudeStatusProbeError.parseFailed("Claude CLI /usage is still loading usage data.") + } + // Claude CLI renders /usage as a TUI. Our PTY capture includes earlier screen fragments (including a status // line // with a "0%" context meter) before the usage panel is drawn. To keep parsing stable, trim to the last @@ -452,6 +461,12 @@ public struct ClaudeStatusProbe: Sendable { return nil } + private static func isUsageStillLoading(text: String) -> Bool { + let normalized = TextParsing.stripANSICodes(text).lowercased().filter { !$0.isWhitespace } + guard normalized.contains("loadingusage") else { return false } + return !self.usageCaptureHasSessionValue(normalized) && self.allPercents(text).isEmpty + } + /// Collect remaining percentages in the order they appear; used as a backup when labels move/rename. private static func allPercents(_ text: String) -> [Int] { let lines = text.components(separatedBy: .newlines) diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index 9da973423..e64540011 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -71,6 +71,7 @@ public enum ClaudeUsageError: LocalizedError, Sendable { public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { private static let sessionWindowMinutes = 5 * 60 private static let weeklyWindowMinutes = 7 * 24 * 60 + private static let cliAutoProbeTimeout: TimeInterval = 12 private static let cliProbeTimeout: TimeInterval = 24 private static let cliRetryProbeTimeout: TimeInterval = 60 private struct Configuration { @@ -541,7 +542,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { case .web: return try await self.fetcher.loadViaWebAPI() case .cli: - return try await self.loadViaCLIWithRetry(model: model) + return try await self.loadViaCLI(model: model, timeout: ClaudeUsageFetcher.cliAutoProbeTimeout) case .auto: throw ClaudeUsageError.parseFailed("Planner emitted invalid auto execution step.") } diff --git a/Tests/CodexBarTests/ClaudeCLITimeoutRetryTests.swift b/Tests/CodexBarTests/ClaudeCLITimeoutRetryTests.swift index 3acbf2686..a7f2597c0 100644 --- a/Tests/CodexBarTests/ClaudeCLITimeoutRetryTests.swift +++ b/Tests/CodexBarTests/ClaudeCLITimeoutRetryTests.swift @@ -59,6 +59,32 @@ struct ClaudeCLITimeoutRetryTests { #expect(snapshot.accountEmail == "cli@example.com") } + @Test + func `auto cli usage uses bounded timeout without long retry`() async throws { + let attempts = AttemptRecorder() + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: [:], + dataSource: .auto) + + let fetchOverride: ClaudeStatusProbe.FetchOverride = { _, timeout, _ in + _ = await attempts.record(timeout: timeout) + throw ClaudeStatusProbeError.parseFailed("Claude CLI /usage is still loading usage data.") + } + + await #expect(throws: ClaudeStatusProbeError.self) { + try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/usr/bin/true") { + try await ClaudeStatusProbe.withFetchOverrideForTesting(fetchOverride) { + try await fetcher.loadLatestUsage(model: "sonnet") + } + } + } + + let recorded = await attempts.snapshot() + #expect(recorded.count == 1) + #expect(recorded.timeouts == [12]) + } + @Test func `cli usage does not retry cancelled probe`() async throws { let attempts = AttemptRecorder() diff --git a/Tests/CodexBarTests/StatusProbeTests.swift b/Tests/CodexBarTests/StatusProbeTests.swift index 10677a10f..da401dcca 100644 --- a/Tests/CodexBarTests/StatusProbeTests.swift +++ b/Tests/CodexBarTests/StatusProbeTests.swift @@ -288,7 +288,7 @@ struct StatusProbeTests { } @Test - func `parse claude status loading panel does not report zero percent`() { + func `parse claude status loading panel surfaces loading stall`() { let sample = """ Claude Code v2.1.29 22:47 | | Opus 4.5 | default | ░░░░░░░░░░ 0% ◯ /ide for Visual Studio Code @@ -301,7 +301,8 @@ struct StatusProbeTests { do { _ = try ClaudeStatusProbe.parse(text: sample) #expect(Bool(false), "Parsing should fail while /usage is still loading") - } catch ClaudeStatusProbeError.parseFailed { + } catch let ClaudeStatusProbeError.parseFailed(message) { + #expect(message.lowercased().contains("loading")) return } catch ClaudeStatusProbeError.timedOut { return From 5291a064d71956fb5ad7208bccfd54d523c4e4c4 Mon Sep 17 00:00:00 2001 From: rohitjavvadi Date: Mon, 18 May 2026 22:29:57 +0530 Subject: [PATCH 2/2] Fix auto Claude CLI timeout retry --- .../Providers/Claude/ClaudeUsageFetcher.swift | 23 +++++- .../ClaudeCLITimeoutRetryTests.swift | 75 ++++++++++++++++++- 2 files changed, 91 insertions(+), 7 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index e64540011..0f8bc21ad 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -477,10 +477,11 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { let executionSteps = plan.executionSteps for (index, step) in executionSteps.enumerated() { + let isFinalStep = index == executionSteps.count - 1 do { - return try await self.execute(step: step, model: model) + return try await self.execute(step: step, model: model, isFinalStep: isFinalStep) } catch { - if index < executionSteps.count - 1 { + if !isFinalStep { ClaudeUsageFetcher.log.debug( "Claude planner step failed; falling back to next step", metadata: [ @@ -531,7 +532,11 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { ClaudeUsageFetcher.log.debug("Claude auto source planner", metadata: metadata) } - private func execute(step: ClaudeFetchPlanStep, model: String) async throws -> ClaudeUsageSnapshot { + private func execute( + step: ClaudeFetchPlanStep, + model: String, + isFinalStep: Bool) async throws -> ClaudeUsageSnapshot + { switch step.dataSource { case .api: throw ClaudeUsageError.parseFailed("Planner emitted invalid api execution step.") @@ -542,12 +547,22 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { case .web: return try await self.fetcher.loadViaWebAPI() case .cli: - return try await self.loadViaCLI(model: model, timeout: ClaudeUsageFetcher.cliAutoProbeTimeout) + return try await self.loadViaAutoCLI(model: model, isFinalStep: isFinalStep) case .auto: throw ClaudeUsageError.parseFailed("Planner emitted invalid auto execution step.") } } + private func loadViaAutoCLI(model: String, isFinalStep: Bool) async throws -> ClaudeUsageSnapshot { + do { + return try await self.loadViaCLI(model: model, timeout: ClaudeUsageFetcher.cliAutoProbeTimeout) + } catch { + if error is CancellationError { throw error } + guard isFinalStep, Self.shouldRetryCLIProbe(after: error) else { throw error } + return try await self.loadViaCLI(model: model, timeout: ClaudeUsageFetcher.cliRetryProbeTimeout) + } + } + private func loadViaCLIWithRetry(model: String) async throws -> ClaudeUsageSnapshot { do { return try await self.loadViaCLI(model: model, timeout: ClaudeUsageFetcher.cliProbeTimeout) diff --git a/Tests/CodexBarTests/ClaudeCLITimeoutRetryTests.swift b/Tests/CodexBarTests/ClaudeCLITimeoutRetryTests.swift index a7f2597c0..87e90a5a3 100644 --- a/Tests/CodexBarTests/ClaudeCLITimeoutRetryTests.swift +++ b/Tests/CodexBarTests/ClaudeCLITimeoutRetryTests.swift @@ -65,7 +65,8 @@ struct ClaudeCLITimeoutRetryTests { let fetcher = ClaudeUsageFetcher( browserDetection: BrowserDetection(cacheTTL: 0), environment: [:], - dataSource: .auto) + dataSource: .auto, + manualCookieHeader: "foo=bar") let fetchOverride: ClaudeStatusProbe.FetchOverride = { _, timeout, _ in _ = await attempts.record(timeout: timeout) @@ -73,6 +74,48 @@ struct ClaudeCLITimeoutRetryTests { } await #expect(throws: ClaudeStatusProbeError.self) { + try await self.withNoOAuthCredentials { + try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/usr/bin/true") { + try await ClaudeStatusProbe.withFetchOverrideForTesting(fetchOverride) { + try await fetcher.loadLatestUsage(model: "sonnet") + } + } + } + } + + let recorded = await attempts.snapshot() + #expect(recorded.count == 1) + #expect(recorded.timeouts == [12]) + } + + @Test + func `auto cli usage retries timeout when cli is final source`() async throws { + let attempts = AttemptRecorder() + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: [:], + dataSource: .auto, + manualCookieHeader: "foo=bar") + + let fetchOverride: ClaudeStatusProbe.FetchOverride = { _, timeout, _ in + let attempt = await attempts.record(timeout: timeout) + if attempt == 1 { + throw ClaudeStatusProbeError.timedOut + } + return ClaudeStatusSnapshot( + sessionPercentLeft: 72, + weeklyPercentLeft: 64, + opusPercentLeft: nil, + accountEmail: "auto-cli@example.com", + accountOrganization: "Auto CLI Org", + loginMethod: "cli", + primaryResetDescription: nil, + secondaryResetDescription: nil, + opusResetDescription: nil, + rawText: "probe raw") + } + + let snapshot = try await self.withNoOAuthCredentials { try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/usr/bin/true") { try await ClaudeStatusProbe.withFetchOverrideForTesting(fetchOverride) { try await fetcher.loadLatestUsage(model: "sonnet") @@ -81,8 +124,11 @@ struct ClaudeCLITimeoutRetryTests { } let recorded = await attempts.snapshot() - #expect(recorded.count == 1) - #expect(recorded.timeouts == [12]) + #expect(recorded.count == 2) + #expect(recorded.timeouts == [12, 60]) + #expect(snapshot.primary.usedPercent == 28) + #expect(snapshot.secondary?.usedPercent == 36) + #expect(snapshot.accountEmail == "auto-cli@example.com") } @Test @@ -110,4 +156,27 @@ struct ClaudeCLITimeoutRetryTests { #expect(recorded.count == 1) #expect(recorded.timeouts == [24]) } + + private func withNoOAuthCredentials(operation: () async throws -> T) async rethrows -> T { + let missingCredentialsURL = FileManager.default.temporaryDirectory + .appendingPathComponent("missing-claude-creds-\(UUID().uuidString).json") + return try await KeychainCacheStore.withServiceOverrideForTesting("rat-107-\(UUID().uuidString)") { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + return try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(missingCredentialsURL) { + try await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { + try await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: nil, + fingerprint: nil) + { + try await operation() + } + } + } + } + } + } + } }