Skip to content

Commit dcfbd49

Browse files
gi11esclaude
andcommitted
fix: context usage bar flickering and thread safety in monitors
The context bar would disappear when switching between Claude tabs because the 64KB tail read was too small for active Opus sessions with large tool results. Also fixes data races in both ContextMonitor and QuotaMonitor. - Progressive tail read (256KB → 1MB) in ContextMonitor.getUsage - Per-session usage cache so the bar doesn't flicker to nil - Thread-safe usageCache in ContextMonitor (NSLock) - Thread-safe tokenRate/sparklineData in QuotaMonitor (NSLock) - Stale callback guard in updateContextUsage (ignores results for tabs the user already navigated away from) - Diagnostic logging for context usage path - 16 new tests covering parseUsage, getUsageFromFile, and caching Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
1 parent a099e8a commit dcfbd49

File tree

4 files changed

+356
-83
lines changed

4 files changed

+356
-83
lines changed

Sources/Detection/ContextMonitor.swift

Lines changed: 80 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,7 @@ class ContextMonitor {
6565
try? fh.close()
6666

6767
if let headStr = String(data: headData, encoding: .utf8) {
68-
let lines = headStr.components(separatedBy: "\n")
69-
for line in lines where !line.isEmpty {
68+
for line in headStr.split(separator: "\n") {
7069
guard let ld = line.data(using: .utf8),
7170
let json = try? JSONSerialization.jsonObject(with: ld) as? [String: Any] else { continue }
7271

@@ -85,7 +84,7 @@ class ContextMonitor {
8584
}
8685
}
8786

88-
firstMessage = firstMessage.components(separatedBy: "\n").first ?? ""
87+
firstMessage = firstMessage.split(separator: "\n").first.map(String.init) ?? ""
8988
firstMessage = firstMessage.trimmingCharacters(in: .whitespacesAndNewlines)
9089

9190
results.append(SessionInfo(
@@ -114,7 +113,7 @@ class ContextMonitor {
114113
let iso8601 = ISO8601DateFormatter()
115114
iso8601.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
116115

117-
for line in content.components(separatedBy: "\n") where !line.isEmpty {
116+
for line in content.split(separator: "\n") {
118117
guard let lineData = line.data(using: .utf8),
119118
let json = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any],
120119
let type = json["type"] as? String, type == "user",
@@ -169,7 +168,7 @@ class ContextMonitor {
169168
var currentTurnIndex = -1
170169
var seenPromptIds = Set<String>()
171170

172-
for line in content.components(separatedBy: "\n") where !line.isEmpty {
171+
for line in content.split(separator: "\n") {
173172
guard let lineData = line.data(using: .utf8),
174173
let json = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any],
175174
let type = json["type"] as? String else { continue }
@@ -203,7 +202,7 @@ class ContextMonitor {
203202
let filename = (fp as NSString).lastPathComponent
204203
desc = "\(name) \(filename)"
205204
} else if let cmd = input["command"] as? String {
206-
let brief = cmd.components(separatedBy: "\n").first ?? cmd
205+
let brief = cmd.split(separator: "\n").first.map(String.init) ?? cmd
207206
desc = "\(name): \(String(brief.prefix(50)))"
208207
} else if let pattern = input["pattern"] as? String {
209208
desc = "\(name) \(pattern)"
@@ -226,7 +225,7 @@ class ContextMonitor {
226225
guard let data = try? Data(contentsOf: URL(fileURLWithPath: jsonlPath)),
227226
let content = String(data: data, encoding: .utf8) else { return nil }
228227

229-
let lines = content.components(separatedBy: "\n")
228+
let lines = content.split(separator: "\n", omittingEmptySubsequences: false)
230229
var seenPromptIds = Set<String>()
231230
var uniqueTurnCount = -1 // will be incremented to 0 on first user turn
232231
var cutoffLineIndex = lines.count
@@ -276,66 +275,103 @@ class ContextMonitor {
276275
}
277276
}
278277

278+
/// Per-session cache so we don't flicker the context bar to nil when a tail
279+
/// read misses the usage entry (e.g. large tool-result block at end of file).
280+
/// Access only via `cachedUsage(_:)` and `setCachedUsage(_:for:)`.
281+
private var usageCache: [String: ContextUsage] = [:]
282+
private let cacheLock = NSLock()
283+
284+
private func cachedUsage(_ sessionId: String) -> ContextUsage? {
285+
cacheLock.lock()
286+
defer { cacheLock.unlock() }
287+
return usageCache[sessionId]
288+
}
289+
290+
private func setCachedUsage(_ usage: ContextUsage, for sessionId: String) {
291+
cacheLock.lock()
292+
defer { cacheLock.unlock() }
293+
usageCache[sessionId] = usage
294+
}
295+
279296
/// Get context usage for a session by reading its JSONL file.
280297
/// Only reads the tail of the file to find the most recent usage entry.
298+
/// Falls back to a cached value when the tail doesn't contain a usage entry.
281299
func getUsage(sessionId: String, projectPath: String) -> ContextUsage? {
282300
let encoded = projectPath.claudeProjectDirName
283301
let jsonlPath = NSHomeDirectory() + "/.claude/projects/\(encoded)/\(sessionId).jsonl"
284302

303+
if let usage = getUsageFromFile(at: jsonlPath) {
304+
setCachedUsage(usage, for: sessionId)
305+
DiagnosticLog.shared.log("context",
306+
"getUsage: \(sessionId) \(usage.contextUsed)/\(usage.contextLimit) (\(Int(usage.percentage))%) model=\(usage.model)")
307+
return usage
308+
}
309+
310+
// No usage found — return cached value if available
311+
let cached = cachedUsage(sessionId)
312+
DiagnosticLog.shared.log("context",
313+
"getUsage: \(sessionId) no usage found, cached=\(cached != nil)")
314+
return cached
315+
}
316+
317+
/// Parse context usage from a JSONL file at the given path.
318+
/// Uses a progressive tail read (256KB then 1MB) to handle large files
319+
/// where tool results push usage entries far from the end.
320+
func getUsageFromFile(at jsonlPath: String) -> ContextUsage? {
285321
guard let fh = FileHandle(forReadingAtPath: jsonlPath) else { return nil }
286322
defer { try? fh.close() }
287323

288324
let fileSize = fh.seekToEndOfFile()
289325
guard fileSize > 0 else { return nil }
290326

291-
// --- Find last usage: read only the tail of the file ---
292-
// Usage entries appear near the end. Read the last 64KB (enough for
293-
// several assistant response entries).
294-
let tailSize: UInt64 = 64 * 1024
295-
let tailOffset = fileSize > tailSize ? fileSize - tailSize : 0
296-
fh.seek(toFileOffset: tailOffset)
297-
let tailData = fh.readData(ofLength: Int(fileSize - tailOffset))
298-
guard let tailContent = String(data: tailData, encoding: .utf8) else { return nil }
299-
300-
var lastInput = 0
301-
var lastCacheRead = 0
302-
var model = ""
303-
304-
// Split tail into lines and scan in reverse for the last usage entry
305-
let lines = tailContent.components(separatedBy: "\n")
327+
// --- Progressive tail read ---
328+
// Start with 256KB; if that misses, try 1MB. Large tool results
329+
// (file reads, grep output) can easily exceed 64KB.
330+
let tailSizes: [UInt64] = [256 * 1024, 1024 * 1024]
331+
332+
for tailSize in tailSizes {
333+
let tailOffset = fileSize > tailSize ? fileSize - tailSize : 0
334+
fh.seek(toFileOffset: tailOffset)
335+
let tailData = fh.readData(ofLength: Int(fileSize - tailOffset))
336+
guard let tailContent = String(data: tailData, encoding: .utf8) else { continue }
337+
338+
if let usage = parseUsage(from: tailContent) {
339+
return usage
340+
}
341+
}
342+
343+
return nil
344+
}
345+
346+
/// Parse the last usage entry from JSONL content, scanning lines in reverse.
347+
func parseUsage(from content: String) -> ContextUsage? {
348+
let lines = content.split(separator: "\n")
306349
for line in lines.reversed() {
307-
guard !line.isEmpty else { continue }
308350
guard let lineData = line.data(using: .utf8),
309351
let json = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any] else { continue }
310352

311353
if let msg = json["message"] as? [String: Any], let usage = msg["usage"] as? [String: Any] {
312-
lastInput = usage["input_tokens"] as? Int ?? 0
313-
lastCacheRead = usage["cache_read_input_tokens"] as? Int ?? 0
314-
if lastInput + lastCacheRead == 0 { continue }
315-
model = msg["model"] as? String ?? model
316-
break
354+
let input = usage["input_tokens"] as? Int ?? 0
355+
let cacheRead = usage["cache_read_input_tokens"] as? Int ?? 0
356+
if input + cacheRead == 0 { continue }
357+
let model = msg["model"] as? String ?? ""
358+
let limit = contextLimits[model] ?? defaultLimit
359+
return ContextUsage(model: model, inputTokens: input,
360+
cacheReadTokens: cacheRead, contextLimit: limit)
317361
}
318362

319363
if let msg = json["message"] as? [String: Any],
320364
let inner = msg["message"] as? [String: Any],
321365
let usage = inner["usage"] as? [String: Any] {
322-
lastInput = usage["input_tokens"] as? Int ?? 0
323-
lastCacheRead = usage["cache_read_input_tokens"] as? Int ?? 0
324-
if lastInput + lastCacheRead == 0 { continue }
325-
model = inner["model"] as? String ?? model
326-
break
366+
let input = usage["input_tokens"] as? Int ?? 0
367+
let cacheRead = usage["cache_read_input_tokens"] as? Int ?? 0
368+
if input + cacheRead == 0 { continue }
369+
let model = inner["model"] as? String ?? ""
370+
let limit = contextLimits[model] ?? defaultLimit
371+
return ContextUsage(model: model, inputTokens: input,
372+
cacheReadTokens: cacheRead, contextLimit: limit)
327373
}
328374
}
329-
330-
guard !model.isEmpty || lastInput > 0 || lastCacheRead > 0 else { return nil }
331-
332-
let limit = contextLimits[model] ?? defaultLimit
333-
334-
return ContextUsage(
335-
model: model,
336-
inputTokens: lastInput,
337-
cacheReadTokens: lastCacheRead,
338-
contextLimit: limit,
339-
)
375+
return nil
340376
}
341377
}

Sources/Detection/QuotaMonitor.swift

Lines changed: 47 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,28 @@ class QuotaMonitor {
3333
return nil
3434
}
3535

36-
private(set) var tokenRate: TokenRate?
37-
private(set) var sparklineData: [Double] = [] // Ring buffer, max 30 points
38-
private var lastSparklinePush: Date = .distantPast
36+
/// Lock protecting `_tokenRate`, `_sparklineData`, and `_lastSparklinePush`
37+
/// which are written from a background queue in `computeTokenRate` and
38+
/// read from the main thread.
39+
private let rateLock = NSLock()
40+
private var _tokenRate: TokenRate?
41+
private var _sparklineData: [Double] = [] // Ring buffer, max 30 points
42+
private var _lastSparklinePush: Date = .distantPast
3943
private let sparklineMaxPoints = 30
4044
private let sparklinePushInterval: TimeInterval = 10 // seconds between data points
4145

46+
var tokenRate: TokenRate? {
47+
rateLock.lock()
48+
defer { rateLock.unlock() }
49+
return _tokenRate
50+
}
51+
52+
var sparklineData: [Double] {
53+
rateLock.lock()
54+
defer { rateLock.unlock() }
55+
return _sparklineData
56+
}
57+
4258
init() {
4359
loadCachedSnapshot()
4460
}
@@ -129,7 +145,7 @@ class QuotaMonitor {
129145
}
130146

131147
guard let jsonlPath = bestFile else {
132-
tokenRate = nil
148+
setRate(nil, tokensPerMinute: nil)
133149
return nil
134150
}
135151

@@ -139,14 +155,14 @@ class QuotaMonitor {
139155

140156
// Read last 128KB of the file using FileHandle-based tail reading
141157
guard let fh = FileHandle(forReadingAtPath: jsonlPath) else {
142-
tokenRate = nil
158+
setRate(nil, tokensPerMinute: nil)
143159
return nil
144160
}
145161
defer { try? fh.close() }
146162

147163
let fileSize = fh.seekToEndOfFile()
148164
guard fileSize > 0 else {
149-
tokenRate = nil
165+
setRate(nil, tokensPerMinute: nil)
150166
return nil
151167
}
152168

@@ -155,14 +171,13 @@ class QuotaMonitor {
155171
fh.seek(toFileOffset: tailOffset)
156172
let tailData = fh.readData(ofLength: Int(fileSize - tailOffset))
157173
guard let tailContent = String(data: tailData, encoding: .utf8) else {
158-
tokenRate = nil
174+
setRate(nil, tokensPerMinute: nil)
159175
return nil
160176
}
161177

162178
// Parse lines in reverse looking for output_tokens with timestamps
163-
let lines = tailContent.components(separatedBy: "\n")
179+
let lines = tailContent.split(separator: "\n")
164180
for line in lines.reversed() {
165-
guard !line.isEmpty else { continue }
166181
guard let lineData = line.data(using: .utf8),
167182
let json = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any] else { continue }
168183

@@ -189,7 +204,7 @@ class QuotaMonitor {
189204
}
190205

191206
guard totalOutputTokens > 0, let earliest = earliestTimestamp else {
192-
tokenRate = nil
207+
setRate(nil, tokensPerMinute: nil)
193208
return nil
194209
}
195210

@@ -200,26 +215,36 @@ class QuotaMonitor {
200215
let windowSeconds = Int(now.timeIntervalSince(earliest))
201216

202217
let rate = TokenRate(tokensPerMinute: tokensPerMinute, windowSeconds: windowSeconds)
203-
tokenRate = rate
218+
setRate(rate, tokensPerMinute: tokensPerMinute)
219+
return rate
220+
}
204221

205-
// Push to sparkline ring buffer if enough time has elapsed
206-
if now.timeIntervalSince(lastSparklinePush) >= sparklinePushInterval {
207-
if sparklineData.count >= sparklineMaxPoints {
208-
sparklineData.removeFirst()
222+
/// Thread-safe update of rate state. Called from `computeTokenRate` (background queue).
223+
/// When `rate` is nil the sparkline is left untouched (no data point to push).
224+
private func setRate(_ rate: TokenRate?, tokensPerMinute: Double?) {
225+
rateLock.lock()
226+
defer { rateLock.unlock() }
227+
_tokenRate = rate
228+
if let tpm = tokensPerMinute {
229+
let now = Date()
230+
if now.timeIntervalSince(_lastSparklinePush) >= sparklinePushInterval {
231+
if _sparklineData.count >= sparklineMaxPoints {
232+
_sparklineData.removeFirst()
233+
}
234+
_sparklineData.append(tpm)
235+
_lastSparklinePush = now
209236
}
210-
sparklineData.append(tokensPerMinute)
211-
lastSparklinePush = now
212237
}
213-
214-
return rate
215238
}
216239

217240
/// Clears all state (for unit tests).
218241
func resetForTesting() {
219242
liveSnapshot = nil
220243
cachedSnapshot = nil
221-
tokenRate = nil
222-
sparklineData = []
223-
lastSparklinePush = .distantPast
244+
rateLock.lock()
245+
_tokenRate = nil
246+
_sparklineData = []
247+
_lastSparklinePush = .distantPast
248+
rateLock.unlock()
224249
}
225250
}

Sources/Window/DeckardWindowController.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -920,18 +920,29 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate {
920920
private func updateContextUsage(for tab: TabItem) {
921921
guard let sessionId = tab.sessionId,
922922
let project = currentProject else {
923+
DiagnosticLog.shared.log("context",
924+
"updateContextUsage: skipped — sessionId=\(tab.sessionId ?? "nil") project=\(currentProject != nil)")
923925
quotaView.updateContext(usage: nil, tabName: nil)
924926
return
925927
}
926928

927929
let tabName = tab.name
930+
let tabId = tab.id
928931
let projectPath = project.path
929932
let allPaths = projects.map { $0.path }
930933
DispatchQueue.global(qos: .utility).async {
931934
let usage = ContextMonitor.shared.getUsage(sessionId: sessionId, projectPath: projectPath)
932935
let rate = QuotaMonitor.shared.computeTokenRate(projectPaths: allPaths)
933936
DispatchQueue.main.async { [weak self] in
934937
guard let self = self else { return }
938+
// Only update if this tab is still the active one
939+
guard let project = self.currentProject,
940+
let activeTab = project.tabs[safe: project.selectedTabIndex],
941+
activeTab.id == tabId else {
942+
DiagnosticLog.shared.log("context",
943+
"updateContextUsage: stale callback for \(tabName), ignoring")
944+
return
945+
}
935946
self.quotaView.updateContext(usage: usage, tabName: tabName)
936947
self.quotaView.update(
937948
snapshot: QuotaMonitor.shared.latest,

0 commit comments

Comments
 (0)