diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a333c1f..ba6779cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p ### Fixed - Provider usage now completes the Codex app-server handshake before reading limits, verifies Claude's current login before trusting its bounded fresh cache, hides signed-out providers, refreshes whenever its compact sidebar control opens, and sizes the popover to its visible content. +- The provider usage popover no longer reports that Claude and Codex usage could not be read: reading the Codex app server's silent stderr through `FileHandle.bytes` blocked Foundation's shared pipe reader, so both probes timed out together. A signed-out Claude CLI now hides the provider instead of showing an error, and the sidebar help and usage icons sit on a slightly wider pitch. - Browser context-menu actions no longer crash when a Google redirect contains repeated query parameters. - Crash recovery no longer opens a second window full of empty workspaces when only some detached terminal sessions can be reattached. The recovery window now contains only live recovered sessions and closes when none recover. - Closing a window now tears down every timer, observer, task, panel, and workspace it owns, so closed windows cannot keep empty workspaces alive or reappear in a later session snapshot. diff --git a/Sources/ClaudeQuotaMonitor.swift b/Sources/ClaudeQuotaMonitor.swift index c689efc0..3acbb911 100644 --- a/Sources/ClaudeQuotaMonitor.swift +++ b/Sources/ClaudeQuotaMonitor.swift @@ -224,6 +224,31 @@ enum ClaudeUsageSnapshotParser { } } +/// Streams pipe output through `readabilityHandler` instead of `FileHandle.bytes`. +/// +/// Foundation serves every `FileHandle.bytes` sequence from one shared IO actor +/// that performs blocking reads one at a time. A single idle pipe, such as an +/// app server's silent stderr, therefore starves every other reader in the +/// process, and both provider probes time out together. +enum ProviderUsagePipeReader { + static func chunks(from handle: FileHandle) -> AsyncStream { + AsyncStream { continuation in + handle.readabilityHandler = { handle in + let data = handle.availableData + if data.isEmpty { + handle.readabilityHandler = nil + continuation.finish() + } else { + continuation.yield(data) + } + } + continuation.onTermination = { _ in + handle.readabilityHandler = nil + } + } + } +} + struct ClaudeProviderUsageFetcher: ProviderUsageFetching { let provider = ProviderUsageProvider.claude @@ -290,14 +315,14 @@ struct ClaudeProviderUsageFetcher: ProviderUsageFetching { private var isFinished = false private var failed = false - func append(_ byte: UInt8) { + func append(_ chunk: Data) { guard !failed else { return } - guard data.count < ClaudeProviderUsageFetcher.maximumAuthResponseBytes else { + guard data.count + chunk.count <= ClaudeProviderUsageFetcher.maximumAuthResponseBytes else { data.removeAll(keepingCapacity: false) failed = true return } - data.append(byte) + data.append(chunk) } func finish(readFailed: Bool) { @@ -327,14 +352,10 @@ struct ClaudeProviderUsageFetcher: ProviderUsageFetching { let capture = BoundedCapture() let reader = Task { - do { - for try await byte in stdout.fileHandleForReading.bytes { - await capture.append(byte) - } - await capture.finish(readFailed: false) - } catch { - await capture.finish(readFailed: !Task.isCancelled) + for await chunk in ProviderUsagePipeReader.chunks(from: stdout.fileHandleForReading) { + await capture.append(chunk) } + await capture.finish(readFailed: Task.isCancelled) } let clock = ContinuousClock() let deadline = clock.now.advanced(by: .milliseconds(Int64(max(timeout, 0) * 1_000))) @@ -353,10 +374,10 @@ struct ClaudeProviderUsageFetcher: ProviderUsageFetching { terminate(process) } if !snapshot.isFinished { - try? stdout.fileHandleForReading.close() + reader.cancel() } - reader.cancel() _ = await reader.value + try? stdout.fileHandleForReading.close() guard !Task.isCancelled, snapshot.isFinished, @@ -364,8 +385,9 @@ struct ClaudeProviderUsageFetcher: ProviderUsageFetching { !process.isRunning else { return .failed } - guard process.terminationStatus == 0, - let object = try? JSONSerialization.jsonObject(with: snapshot.data) as? [String: Any], + // The CLI exits non-zero when signed out while still printing + // `{"loggedIn": false}`; a parseable answer beats the exit status. + guard let object = try? JSONSerialization.jsonObject(with: snapshot.data) as? [String: Any], let loggedIn = object["loggedIn"] as? Bool else { return .failed } @@ -751,18 +773,18 @@ struct CodexProviderUsageFetcher: ProviderUsageFetching { let process = Process() let stdin = Pipe() let stdout = Pipe() - let stderr = Pipe() process.executableURL = executableURL process.arguments = ["app-server"] process.standardInput = stdin process.standardOutput = stdout - process.standardError = stderr + // Stderr is intentionally discarded; an attached pipe would need its own + // reader, and an idle reader starves the stdout reader (see ProviderUsagePipeReader). + process.standardError = FileHandle.nullDevice do { try process.run() try? stdout.fileHandleForWriting.close() - try? stderr.fileHandleForWriting.close() } catch { return .failed } @@ -771,9 +793,6 @@ struct CodexProviderUsageFetcher: ProviderUsageFetching { let responseReader = Task { await collectResponses(from: stdout.fileHandleForReading, into: inbox) } - let stderrReader = Task { - await drain(stderr.fileHandleForReading) - } let clock = ContinuousClock() let deadline = clock.now.advanced(by: .milliseconds(Int64(max(timeout, 0) * 1_000))) var outcome = AppServerOutcome.failed @@ -825,12 +844,9 @@ struct CodexProviderUsageFetcher: ProviderUsageFetching { if process.isRunning { terminate(process) } - try? stdout.fileHandleForReading.close() - try? stderr.fileHandleForReading.close() responseReader.cancel() - stderrReader.cancel() _ = await responseReader.value - _ = await stderrReader.value + try? stdout.fileHandleForReading.close() return Task.isCancelled ? .failed : outcome } @@ -846,12 +862,12 @@ struct CodexProviderUsageFetcher: ProviderUsageFetching { var line = Data() var receivedBytes = 0 - do { - for try await byte in handle.bytes { + chunks: for await chunk in ProviderUsagePipeReader.chunks(from: handle) { + for byte in chunk { receivedBytes += 1 if receivedBytes > maximumCapturedBytes { await inbox.markExceededCaptureLimit() - break + break chunks } if byte == 0x0A { @@ -861,13 +877,11 @@ struct CodexProviderUsageFetcher: ProviderUsageFetching { line.append(byte) } } - if !line.isEmpty, receivedBytes <= maximumCapturedBytes { - await inbox.consume(line) - } - await inbox.finish(readFailed: false) - } catch { - await inbox.finish(readFailed: !Task.isCancelled) } + if !line.isEmpty, receivedBytes <= maximumCapturedBytes { + await inbox.consume(line) + } + await inbox.finish(readFailed: Task.isCancelled) } private static func sanitizedAccountEnvelope(_ envelope: [String: Any]) -> Data? { @@ -891,14 +905,6 @@ struct CodexProviderUsageFetcher: ProviderUsageFetching { return try? JSONSerialization.data(withJSONObject: sanitized) } - private static func drain(_ handle: FileHandle) async { - do { - for try await _ in handle.bytes {} - } catch { - // Stderr is intentionally discarded and never surfaced or stored. - } - } - private static func initializeRequestPayload() throws -> Data { var payload = try JSONSerialization.data(withJSONObject: [ "jsonrpc": "2.0", diff --git a/Sources/SidebarVisuals.swift b/Sources/SidebarVisuals.swift index 1d47692b..cb8c44db 100644 --- a/Sources/SidebarVisuals.swift +++ b/Sources/SidebarVisuals.swift @@ -32,7 +32,9 @@ struct SidebarFooter: View { enum SidebarFooterControlLayout { static let buttonSize: CGFloat = 44 - static let visualPitch: CGFloat = 20 + /// Traffic lights sit on a 20pt pitch; the thinner outline glyphs need one + /// extra grid step to read as evenly spaced next to them. + static let visualPitch: CGFloat = 24 static func helpIconOffset(clustersWithUsage: Bool) -> CGFloat { clustersWithUsage ? (buttonSize - visualPitch) / 2 : 0 diff --git a/programaTests/ClaudeQuotaSnapshotParserTests.swift b/programaTests/ClaudeQuotaSnapshotParserTests.swift index ce6b061e..23a49b2b 100644 --- a/programaTests/ClaudeQuotaSnapshotParserTests.swift +++ b/programaTests/ClaudeQuotaSnapshotParserTests.swift @@ -150,6 +150,26 @@ final class ClaudeQuotaSnapshotParserTests: XCTestCase { ) } + func testSignedOutClaudeCLIExitStatusStillHidesTheProviderInsteadOfFailing() async throws { + let now = Date(timeIntervalSince1970: 1_785_168_986) + let fixture = try ClaudeUsageFixture.make( + authBody: #"print -r -- '{"loggedIn":false}'; exit 1"#, + cacheData: payload(updatedAt: String(Int(now.timeIntervalSince1970 * 1_000))) + ) + addTeardownBlock { try? FileManager.default.removeItem(at: fixture.directoryURL) } + + let result = await ClaudeProviderUsageFetcher.fetchForTesting( + executableURL: fixture.executableURL, + cacheURL: fixture.cacheURL, + timeout: 0.5, + now: now + ) + + guard case .unavailable(.claude) = result else { + return XCTFail("The official CLI exits 1 when signed out; a parseable answer must hide the provider, got \(result)") + } + } + func testLoggedInClaudeWithAFreshRegularBoundedCacheIsAvailable() async throws { let now = Date(timeIntervalSince1970: 1_785_168_986) let fixture = try ClaudeUsageFixture.make( @@ -459,6 +479,73 @@ final class CodexUsageSnapshotParserTests: XCTestCase { ) } + func testLongLivedServerWithAnIdleStderrPipeDoesNotStallTheResponseReader() async throws { + let fake = try FakeCodexAppServer.make( + initializationResponse: #"{"jsonrpc":"2.0","id":0,"result":{}}"#, + responseDelay: 0.01, + lingerAfterResponses: 3 + ) + addTeardownBlock { try? FileManager.default.removeItem(at: fake.directoryURL) } + let clock = ContinuousClock() + let startedAt = clock.now + + let result = await CodexProviderUsageFetcher.fetchForTesting( + executableURL: fake.executableURL, + timeout: 1 + ) + + guard case let .available(snapshot) = result, snapshot.provider == .codex else { + return XCTFail("The real app server stays alive after answering; its responses must be read anyway, got \(result)") + } + XCTAssertLessThan(startedAt.duration(to: clock.now), .milliseconds(800)) + } + + func testConcurrentClaudeAndCodexProbesDoNotStarveEachOther() async throws { + let now = Date(timeIntervalSince1970: 1_785_168_986) + let claude = try ClaudeUsageFixture.make( + authBody: #"print -r -- '{"loggedIn":true}'"#, + cacheData: Data(""" + {"five_hour":{"used_percentage":17,"resets_at":"1785171600"}, + "seven_day":{"used_percentage":3,"resets_at":"1785664800"}, + "updated_at":\(Int(now.timeIntervalSince1970 * 1_000))} + """.utf8) + ) + let codex = try FakeCodexAppServer.make( + initializationResponse: #"{"jsonrpc":"2.0","id":0,"result":{}}"#, + responseDelay: 0.01, + lingerAfterResponses: 3 + ) + addTeardownBlock { + try? FileManager.default.removeItem(at: claude.directoryURL) + try? FileManager.default.removeItem(at: codex.directoryURL) + } + + let results = await withTaskGroup(of: ProviderUsageResult.self, returning: [ProviderUsageResult].self) { group in + group.addTask { + await ClaudeProviderUsageFetcher.fetchForTesting( + executableURL: claude.executableURL, + cacheURL: claude.cacheURL, + timeout: 1, + now: now + ) + } + group.addTask { + await CodexProviderUsageFetcher.fetchForTesting(executableURL: codex.executableURL, timeout: 1) + } + var collected: [ProviderUsageResult] = [] + for await result in group { + collected.append(result) + } + return collected + } + + for result in results { + guard case .available = result else { + return XCTFail("Both providers are probed together when the popover opens; neither may time out, got \(results)") + } + } + } + func testFastExitingServerStillReturnsTheFinalAccountAndRateLimitResponses() async throws { let fake = try FakeCodexAppServer.make( initializationResponse: #"{"jsonrpc":"2.0","id":0,"result":{}}"#, @@ -519,7 +606,8 @@ private struct FakeCodexAppServer { static func make( initializationResponse: String, - responseDelay: TimeInterval = 0.12 + responseDelay: TimeInterval = 0.12, + lingerAfterResponses: TimeInterval = 0 ) throws -> Self { try makeScript { eventsPath in """ @@ -543,6 +631,7 @@ private struct FakeCodexAppServer { fi print -r -- '{"jsonrpc":"2.0","id":1,"result":{"account":{"type":"chatgpt"}}}' print -r -- '{"jsonrpc":"2.0","id":2,"result":{"rateLimits":{"limitId":"codex","limitName":null,"primary":{"usedPercent":24,"windowDurationMins":300,"resetsAt":1785171600},"secondary":{"usedPercent":31,"windowDurationMins":10080,"resetsAt":1785664800}},"rateLimitsByLimitId":{}}}' + sleep \(lingerAfterResponses) """ } }