Skip to content

Commit ca63a8e

Browse files
committed
fix: make cost usage scans cancellation-aware
1 parent 1137192 commit ca63a8e

9 files changed

Lines changed: 605 additions & 199 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
- Codex: show Codex Spark model-specific usage as an optional extra quota lane (#1195, fixes #1177). Thanks @LeoLin990405!
1313

1414
### Fixed
15+
- Cost history: make token-cost JSONL scans cancellation-aware so quitting, forced refreshes, and account switches can stop stale scans sooner.
1516
- Codex: show captured `codex login` output when managed Add Account fails so users can recover from account-selection or OAuth failures (#1199). Thanks @chapati23!
1617
- Claude: hide the obsolete Design quota lane now that Claude Design shares the main Claude usage limit (#1197).
1718
- Menu bar: coalesce visible-menu rebuilds and reduce hover highlight work so the dropdown stays responsive on macOS 26.5 (#1196).

Sources/CodexBarCore/CostUsageFetcher.swift

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,18 @@ public struct CostUsageFetcher: Sendable {
9898
if forceRefresh {
9999
options.refreshMinIntervalSeconds = 0
100100
}
101-
var daily = CostUsageScanner.loadDailyReport(
101+
let checkCancellation: CostUsageScanner.CancellationCheck = {
102+
try Task.checkCancellation()
103+
}
104+
try Task.checkCancellation()
105+
var daily = try CostUsageScanner.loadDailyReportCancellable(
102106
provider: provider,
103107
since: since,
104108
until: until,
105109
now: now,
106-
options: options)
110+
options: options,
111+
checkCancellation: checkCancellation)
112+
try Task.checkCancellation()
107113

108114
if provider == .vertexai,
109115
!allowVertexClaudeFallback,
@@ -112,12 +118,14 @@ public struct CostUsageFetcher: Sendable {
112118
{
113119
var fallback = options
114120
fallback.claudeLogProviderFilter = .all
115-
daily = CostUsageScanner.loadDailyReport(
121+
daily = try CostUsageScanner.loadDailyReportCancellable(
116122
provider: provider,
117123
since: since,
118124
until: until,
119125
now: now,
120-
options: fallback)
126+
options: fallback,
127+
checkCancellation: checkCancellation)
128+
try Task.checkCancellation()
121129
}
122130

123131
if provider == .codex || provider == .claude {
@@ -128,12 +136,14 @@ public struct CostUsageFetcher: Sendable {
128136
if forceRefresh {
129137
piOptions.refreshMinIntervalSeconds = 0
130138
}
131-
let piReport = PiSessionCostScanner.loadDailyReport(
139+
let piReport = try PiSessionCostScanner.loadDailyReportCancellable(
132140
provider: provider,
133141
since: since,
134142
until: until,
135143
now: now,
136-
options: piOptions)
144+
options: piOptions,
145+
checkCancellation: checkCancellation)
146+
try Task.checkCancellation()
137147
daily = CostUsageDailyReport.merged([daily, piReport])
138148
}
139149

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand.
22

33
enum CodexParserHash {
4-
static let value = "5387f958e9e06f7d"
4+
static let value = "0d6c12b99dd77d4e"
55
}

Sources/CodexBarCore/PiSessionCostScanner.swift

Lines changed: 93 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ enum PiSessionCostScanner {
3636
let cacheRoot: URL?
3737
}
3838

39+
private struct ScanContext {
40+
let range: CostUsageScanner.CostUsageDayRange
41+
let forceRescan: Bool
42+
let pricingContext: ModelsDevPricingContext
43+
let checkCancellation: CostUsageScanner.CancellationCheck?
44+
}
45+
3946
private static let costScale = 1_000_000_000.0
4047
private static let maxLineBytes = 16 * 1024 * 1024
4148
private static let maxSafeRoundedInt = Double(Int.max) - 1
@@ -46,6 +53,24 @@ enum PiSessionCostScanner {
4653
until: Date,
4754
now: Date = Date(),
4855
options: Options = Options()) -> CostUsageDailyReport
56+
{
57+
(
58+
try? self.loadDailyReportCancellable(
59+
provider: provider,
60+
since: since,
61+
until: until,
62+
now: now,
63+
options: options,
64+
checkCancellation: nil)) ?? CostUsageDailyReport(data: [], summary: nil)
65+
}
66+
67+
static func loadDailyReportCancellable(
68+
provider: UsageProvider,
69+
since: Date,
70+
until: Date,
71+
now: Date = Date(),
72+
options: Options = Options(),
73+
checkCancellation: CostUsageScanner.CancellationCheck?) throws -> CostUsageDailyReport
4974
{
5075
guard provider == .codex || provider == .claude else {
5176
return CostUsageDailyReport(data: [], summary: nil)
@@ -66,19 +91,23 @@ enum PiSessionCostScanner {
6691
|| nowMs - cache.lastScanUnixMs > refreshMs
6792

6893
if shouldRefresh {
94+
try checkCancellation?()
6995
let root = self.defaultPiSessionsRoot(options: options)
7096
let startCutoff = self.dateFromDayKey(range.scanSinceKey) ?? since
7197
let files = self.listPiSessionFiles(root: root, startCutoffLocal: startCutoff)
7298
let filePathsInScan = Set(files.map(\.path))
7399

74100
for fileURL in files {
75-
self.scanPiSessionFile(
101+
try self.scanPiSessionFile(
76102
fileURL: fileURL,
77-
range: range,
78-
forceRescan: options.forceRescan || windowExpanded,
79-
pricingContext: pricingContext,
80-
cache: &cache)
103+
cache: &cache,
104+
context: ScanContext(
105+
range: range,
106+
forceRescan: options.forceRescan || windowExpanded,
107+
pricingContext: pricingContext,
108+
checkCancellation: checkCancellation))
81109
}
110+
try checkCancellation?()
82111

83112
for key in cache.files.keys where !filePathsInScan.contains(key) {
84113
if let old = cache.files[key] {
@@ -93,6 +122,7 @@ enum PiSessionCostScanner {
93122
cache.scanSinceKey = range.scanSinceKey
94123
cache.scanUntilKey = range.scanUntilKey
95124
cache.lastScanUnixMs = nowMs
125+
try checkCancellation?()
96126
PiSessionCostCacheIO.save(cache: cache, cacheRoot: options.cacheRoot)
97127
}
98128

@@ -176,11 +206,11 @@ enum PiSessionCostScanner {
176206

177207
private static func scanPiSessionFile(
178208
fileURL: URL,
179-
range: CostUsageScanner.CostUsageDayRange,
180-
forceRescan: Bool,
181-
pricingContext: ModelsDevPricingContext,
182-
cache: inout PiSessionCostCache)
209+
cache: inout PiSessionCostCache,
210+
context: ScanContext)
211+
throws
183212
{
213+
try context.checkCancellation?()
184214
let path = fileURL.path
185215
let attrs = (try? FileManager.default.attributesOfItem(atPath: path)) ?? [:]
186216
let mtime = (attrs[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0
@@ -192,26 +222,27 @@ enum PiSessionCostScanner {
192222
}
193223

194224
let cached = cache.files[path]
195-
if !forceRescan,
225+
if !context.forceRescan,
196226
let cached,
197227
cached.mtimeUnixMs == mtimeMs,
198228
cached.size == size
199229
{
200230
return
201231
}
202232

203-
if !forceRescan,
233+
if !context.forceRescan,
204234
let cached,
205235
size > cached.size,
206236
cached.parsedBytes > 0,
207237
cached.parsedBytes <= size
208238
{
209-
let delta = self.parsePiSessionFile(
239+
let delta = try self.parsePiSessionFile(
210240
fileURL: fileURL,
211-
range: range,
241+
range: context.range,
212242
startOffset: cached.parsedBytes,
213243
initialModelContext: cached.lastModelContext,
214-
pricingContext: pricingContext)
244+
pricingContext: context.pricingContext,
245+
checkCancellation: context.checkCancellation)
215246
if !delta.contributions.isEmpty {
216247
self.applyContributions(
217248
daysByProvider: &cache.daysByProvider,
@@ -235,10 +266,11 @@ enum PiSessionCostScanner {
235266
sign: -1)
236267
}
237268

238-
let parsed = self.parsePiSessionFile(
269+
let parsed = try self.parsePiSessionFile(
239270
fileURL: fileURL,
240-
range: range,
241-
pricingContext: pricingContext)
271+
range: context.range,
272+
pricingContext: context.pricingContext,
273+
checkCancellation: context.checkCancellation)
242274
if !parsed.contributions.isEmpty {
243275
self.applyContributions(daysByProvider: &cache.daysByProvider, contributions: parsed.contributions, sign: 1)
244276
}
@@ -256,7 +288,8 @@ enum PiSessionCostScanner {
256288
range: CostUsageScanner.CostUsageDayRange,
257289
startOffset: Int64 = 0,
258290
initialModelContext: PiModelContext? = nil,
259-
pricingContext: ModelsDevPricingContext? = nil) -> ParseResult
291+
pricingContext: ModelsDevPricingContext? = nil,
292+
checkCancellation: CostUsageScanner.CancellationCheck? = nil) throws -> ParseResult
260293
{
261294
var currentModelContext = initialModelContext
262295
var contributions: [String: [String: [String: PiPackedUsage]]] = [:]
@@ -292,41 +325,49 @@ enum PiSessionCostScanner {
292325
}
293326
}
294327

295-
let parsedBytes = (try? CostUsageJsonl.scan(
296-
fileURL: fileURL,
297-
offset: startOffset,
298-
maxLineBytes: Self.maxLineBytes,
299-
prefixBytes: Self.maxLineBytes,
300-
onLine: { line in
301-
guard !line.bytes.isEmpty, !line.wasTruncated else { return }
302-
autoreleasepool {
303-
guard let object = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any]
304-
else { return }
305-
guard let type = object["type"] as? String else { return }
306-
307-
if type == "model_change" {
308-
currentModelContext = self.modelContext(from: object)
309-
return
328+
let parsedBytes: Int64
329+
do {
330+
parsedBytes = try CostUsageJsonl.scan(
331+
fileURL: fileURL,
332+
offset: startOffset,
333+
maxLineBytes: Self.maxLineBytes,
334+
prefixBytes: Self.maxLineBytes,
335+
checkCancellation: checkCancellation,
336+
onLine: { line in
337+
guard !line.bytes.isEmpty, !line.wasTruncated else { return }
338+
autoreleasepool {
339+
guard let object = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any]
340+
else { return }
341+
guard let type = object["type"] as? String else { return }
342+
343+
if type == "model_change" {
344+
currentModelContext = self.modelContext(from: object)
345+
return
346+
}
347+
348+
guard type == "message", let message = object["message"] as? [String: Any] else { return }
349+
guard (message["role"] as? String) == "assistant" else { return }
350+
351+
let identity = self.resolveAssistantIdentity(
352+
entry: object,
353+
message: message,
354+
fallback: currentModelContext)
355+
guard let identity else { return }
356+
guard let date = self.timestampDate(entry: object, message: message) else { return }
357+
let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: date)
358+
let usage = self.extractUsage(
359+
provider: identity.provider,
360+
modelName: identity.modelName,
361+
message: message,
362+
pricingContext: pricingContext)
363+
add(provider: identity.provider, dayKey: dayKey, modelName: identity.modelName, usage: usage)
310364
}
311-
312-
guard type == "message", let message = object["message"] as? [String: Any] else { return }
313-
guard (message["role"] as? String) == "assistant" else { return }
314-
315-
let identity = self.resolveAssistantIdentity(
316-
entry: object,
317-
message: message,
318-
fallback: currentModelContext)
319-
guard let identity else { return }
320-
guard let date = self.timestampDate(entry: object, message: message) else { return }
321-
let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: date)
322-
let usage = self.extractUsage(
323-
provider: identity.provider,
324-
modelName: identity.modelName,
325-
message: message,
326-
pricingContext: pricingContext)
327-
add(provider: identity.provider, dayKey: dayKey, modelName: identity.modelName, usage: usage)
328-
}
329-
})) ?? startOffset
365+
})
366+
} catch is CancellationError {
367+
throw CancellationError()
368+
} catch {
369+
parsedBytes = startOffset
370+
}
330371

331372
return ParseResult(
332373
contributions: contributions,

Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,25 @@ enum CostUsageJsonl {
1414
prefixBytes: Int,
1515
onLine: (Line) -> Void) throws
1616
-> Int64
17+
{
18+
try self.scan(
19+
fileURL: fileURL,
20+
offset: offset,
21+
maxLineBytes: maxLineBytes,
22+
prefixBytes: prefixBytes,
23+
checkCancellation: nil,
24+
onLine: onLine)
25+
}
26+
27+
@discardableResult
28+
static func scan(
29+
fileURL: URL,
30+
offset: Int64 = 0,
31+
maxLineBytes: Int,
32+
prefixBytes: Int,
33+
checkCancellation: (() throws -> Void)? = nil,
34+
onLine: (Line) -> Void) throws
35+
-> Int64
1736
{
1837
let handle = try FileHandle(forReadingFrom: fileURL)
1938
defer { try? handle.close() }
@@ -53,12 +72,14 @@ enum CostUsageJsonl {
5372
}
5473

5574
while true {
75+
try checkCancellation?()
5676
let chunk = try handle.read(upToCount: 256 * 1024) ?? Data()
5777
if chunk.isEmpty {
5878
flushLine()
5979
break
6080
}
6181

82+
try checkCancellation?()
6283
bytesRead += Int64(chunk.count)
6384
chunk.withUnsafeBytes { rawBuffer in
6485
guard let base = rawBuffer.bindMemory(to: UInt8.self).baseAddress else { return }
@@ -76,6 +97,7 @@ enum CostUsageJsonl {
7697
appendSegment(base.advanced(by: segmentStart), count: rawBuffer.count - segmentStart)
7798
}
7899
}
100+
try checkCancellation?()
79101
}
80102

81103
return startOffset + bytesRead

0 commit comments

Comments
 (0)