Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
88 changes: 47 additions & 41 deletions Sources/ClaudeQuotaMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Data> {
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

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)))
Expand All @@ -353,19 +374,20 @@ 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,
!snapshot.failed,
!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
}
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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 {
Expand All @@ -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? {
Expand All @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion Sources/SidebarVisuals.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 90 additions & 1 deletion programaTests/ClaudeQuotaSnapshotParserTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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":{}}"#,
Expand Down Expand Up @@ -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
"""
Expand All @@ -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)
"""
}
}
Expand Down
Loading