Skip to content

Commit bc32c43

Browse files
committed
⚡ perf: fix what the second audit turned up
Three of these are mine from the last round. Format.calendar snapshotted Calendar.current for the life of the process, so an app left running across a time zone change would keep deciding "same day" against the old one; it holds the autoupdating calendar instead. LogBuffer encoded and wrote inside its lock on the caller's thread, usually the main one, so a logger on another thread waited behind a disk write. And the history redraw cache had no expiry, which let a popover left open for an hour flat-line the last sample across time it never fetched, and it cleared isRefreshing while a reload was still running. Hovering the history chart was the worst path in the app: every mouse-move point wrote selectedDate whether or not the nearest point had changed, and Observation publishes on assignment, so each one rebuilt every mark in the chart. Loading the popover's samples ran one query per window and ran whether or not the popover was open. One query covers every window, and the cards are its only reader. The status item built the full attributed title for every rung of the adaptive ladder to measure its width, then built the chosen one again to draw it; both now share one build. Each cell image measured its lines three times, once inside a drawing handler AppKit re-invokes on every scale change. AppController's observation closure captured self strongly and re-registered itself after every change, so the registrar held the controller, its state and its history store for good. Claude-Session: https://claude.ai/code/session_01CGckDFXvufWVmRH4uyE4z8
1 parent 166ba68 commit bc32c43

8 files changed

Lines changed: 74 additions & 38 deletions

File tree

Sources/TokenMenuBarCore/Formatting.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import Foundation
22

33
public enum Format {
44
// `Calendar.current` rebuilds the calendar on every read, and every quota window on screen asks for a reset time
5-
// once a second while the popover is open.
6-
public static let calendar = Calendar.current
5+
// once a second while the popover is open. The autoupdating one is as cheap to hold but follows the user across a
6+
// time zone change, which an app that runs for weeks will see.
7+
public static let calendar = Calendar.autoupdatingCurrent
78

89
public static func percent(_ value: Double, decimals: Int = 0) -> String {
910
min(max(value, 0), 100).formatted(.number.precision(.fractionLength(decimals))) + "%"

Sources/TokenMenuBarCore/Logging/LogBuffer.swift

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ public final class LogBuffer: @unchecked Sendable {
5858
private var entries: [LogEntry] = []
5959
private var lastPrune: Date?
6060
private var lastPersist: Date?
61-
private var dirty = false
6261
private let fileURL: URL?
6362
private let clock: Clock
6463
private let osLog = Logger(subsystem: "dev.tox.token-menu-bar", category: "app")
@@ -98,7 +97,10 @@ public final class LogBuffer: @unchecked Sendable {
9897
case .error: osLog.error("\(message, privacy: .public)")
9998
default: osLog.info("\(message, privacy: .public)")
10099
}
101-
lock.withLock {
100+
// Writing re-encodes the whole buffer and writes it atomically. With detailed logging on that is several lines
101+
// per HTTP request, so the writes are coalesced, and the encode and write stay off the lock: another thread's
102+
// logger would otherwise wait behind this one's disk write, and the caller here is usually the main thread.
103+
let due: [LogEntry]? = lock.withLock {
102104
entries.append(LogEntry(timestamp: clock.now(), level: level, message: message))
103105
if entries.count > Self.capacity { entries.removeFirst(entries.count - Self.capacity) }
104106
let now = clock.now()
@@ -107,21 +109,23 @@ public final class LogBuffer: @unchecked Sendable {
107109
entries.removeAll { $0.timestamp < cutoff }
108110
lastPrune = now
109111
}
110-
dirty = true
111-
// Persisting re-encodes the whole buffer and writes it atomically. With detailed logging on that is several
112-
// hundred entries per HTTP request, so the writes are coalesced instead.
113-
if lastPersist.map({ now.timeIntervalSince($0) >= Self.flushInterval }) ?? true { persist(now: now) }
112+
guard lastPersist.map({ now.timeIntervalSince($0) >= Self.flushInterval }) ?? true else { return nil }
113+
lastPersist = now
114+
return entries
114115
}
116+
if let due { write(due) }
115117
}
116118

117119
/// Writes anything still held in memory. Call before the app goes away or before reading the file back.
118120
public func flush() {
119-
lock.withLock { persist(now: clock.now()) }
121+
let entries: [LogEntry] = lock.withLock {
122+
lastPersist = clock.now()
123+
return self.entries
124+
}
125+
write(entries)
120126
}
121127

122-
private func persist(now: Date) {
123-
lastPersist = now
124-
dirty = false
128+
private func write(_ entries: [LogEntry]) {
125129
guard let fileURL, let data = try? JSONEncoder().encode(entries) else { return }
126130
try? FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
127131
try? data.write(to: fileURL, options: .atomic)
@@ -136,10 +140,8 @@ public final class LogBuffer: @unchecked Sendable {
136140
}
137141

138142
public func clear() {
139-
lock.withLock {
140-
entries.removeAll()
141-
persist(now: clock.now())
142-
}
143+
lock.withLock { entries.removeAll() }
144+
flush()
143145
}
144146

145147
public var text: String {

Sources/TokenMenuBarCore/Presenters/HistoryPresenter.swift

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,13 @@ public final class HistoryPresenter {
5454
private let settings: Settings
5555
private let clock: Clock
5656
private var loadTask: Task<Void, Never>?
57+
private var isLoading = false
58+
/// How long the rows from the last fetch may be redrawn before they are refetched. A sample lands every five
59+
/// minutes, so beyond this the chart would flat-line the last one across time it never fetched.
60+
static let cacheLifetime: TimeInterval = 300
5761
/// The rows the last fetch returned, kept so hiding a series or stacking the chart redraws without going back to
5862
/// the database for the same rows.
59-
private var fetched: (samples: [UsageSample], labels: [WindowKey: String])?
63+
private var fetched: (samples: [UsageSample], labels: [WindowKey: String], at: Date)?
6064

6165
public init(history: UsageHistoryStore, settings: Settings, clock: Clock = .system) {
6266
self.history = history
@@ -95,8 +99,10 @@ public final class HistoryPresenter {
9599
loadTask?.cancel()
96100
let now = clock.now()
97101
if case .loaded(let data, _, _) = state { state = .loaded(data, isRefreshing: true, error: nil) }
102+
isLoading = true
98103
loadTask = Task { [weak self] in
99104
guard let self else { return }
105+
defer { if !Task.isCancelled { isLoading = false } }
100106
do {
101107
let summaries = try await history.summaries()
102108
let earliest = try await history.earliestSample()
@@ -110,7 +116,7 @@ public final class HistoryPresenter {
110116
let labels = Dictionary(
111117
uniqueKeysWithValues: summaries.map { ($0.key, "\($0.key.provider.displayName) \($0.label)") })
112118
guard !Task.isCancelled else { return }
113-
fetched = (samples, labels)
119+
fetched = (samples, labels, now)
114120
let data = ChartPipeline.render(samples: samples, request: request, labels: labels, now: now)
115121
var sections: [ProviderID: [AnalyticsSection]] = [:]
116122
for provider in ProviderID.allCases {
@@ -131,11 +137,11 @@ public final class HistoryPresenter {
131137
/// Re-renders the chart from the rows already in hand. Hiding a series and stacking change what is drawn, not
132138
/// what was recorded, so neither needs the queries `reload` runs.
133139
public func redraw() {
134-
guard let fetched else {
140+
let now = clock.now()
141+
guard let fetched, now.timeIntervalSince(fetched.at) < Self.cacheLifetime, !isLoading else {
135142
reload()
136143
return
137144
}
138-
let now = clock.now()
139145
let data = ChartPipeline.render(
140146
samples: fetched.samples, request: request(now: now), labels: fetched.labels, now: now)
141147
state = .loaded(data, isRefreshing: false, error: nil)
@@ -182,11 +188,14 @@ public final class HistoryPresenter {
182188
}
183189

184190
public func select(x date: Date?) {
191+
// Hover reports every mouse-move point, and most of them land on the point already selected. Observation
192+
// publishes on assignment, so writing the same date rebuilt every mark in the chart.
185193
guard let date, let data = state.data else {
186-
selectedDate = nil
194+
if selectedDate != nil { selectedDate = nil }
187195
return
188196
}
189-
selectedDate = ChartPipeline.nearestDate(in: data, to: date)
197+
let nearest = ChartPipeline.nearestDate(in: data, to: date)
198+
if nearest != selectedDate { selectedDate = nearest }
190199
}
191200

192201
public func value(for series: HistorySeries) -> String {

Sources/TokenMenuBarUI/AppController.swift

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ public final class AppController {
101101
public let environment: UIEnvironment
102102
public let coordinator: RefreshCoordinator
103103
public private(set) var statusItem: StatusItemController?
104+
private var stopped = false
104105
public private(set) var popover: PopoverController?
105106
private var logWindow: LogWindowController?
106107
private var workspaceObservers: [Any] = []
@@ -209,11 +210,13 @@ public final class AppController {
209210
}
210211

211212
func observeStatusModel() {
212-
withObservationTracking {
213-
_ = dependencies.state.statusLadder
213+
// The tracking closure re-registers itself after every change, so capturing self strongly here would leave the
214+
// registrar holding the controller, its state and its history store for good.
215+
withObservationTracking { [weak self] in
216+
_ = self?.dependencies.state.statusLadder
214217
} onChange: {
215218
Task { @MainActor [weak self] in
216-
guard let self else { return }
219+
guard let self, !stopped else { return }
217220
statusItem?.update(ladder: dependencies.state.statusLadder)
218221
observeStatusModel()
219222
}
@@ -227,6 +230,7 @@ public final class AppController {
227230
statusItem = nil
228231
for observer in workspaceObservers { NSWorkspace.shared.notificationCenter.removeObserver(observer) }
229232
workspaceObservers.removeAll()
233+
stopped = true
230234
dependencies.log.log("stopped")
231235
dependencies.log.flush()
232236
}

Sources/TokenMenuBarUI/StatusItem/StatusItemController.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,10 @@ public final class StatusItemController {
134134
}
135135

136136
public func update(ladder: [StatusItemModel]) {
137+
let height = barHeight
138+
let dark = isDark
137139
let widths = ladder.map {
138-
Double(StatusItemRenderer.attributedTitle(for: $0, height: barHeight, dark: isDark).size().width)
140+
Double(StatusItemRenderer.attributedTitle(for: $0, height: height, dark: dark).size().width)
139141
}
140142
self.ladder = AdaptiveWidthPlanner.ladder(ladder, widths: widths)
141143
restart()

Sources/TokenMenuBarUI/StatusItem/StatusItemRenderer.swift

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,19 @@ public enum StatusItemRenderer {
3939
}
4040
}
4141

42+
/// The last title built, so measuring the adaptive ladder and then rendering the entry it picked share one build
43+
/// instead of doing it twice for the same model.
44+
private static var lastTitle: (signature: StatusRenderSignature, title: NSAttributedString)?
45+
4246
public static func attributedTitle(for model: StatusItemModel, height: CGFloat, dark: Bool) -> NSAttributedString {
47+
let signature = StatusRenderSignature(model: model, dark: dark, height: Double(height))
48+
if let lastTitle, lastTitle.signature == signature { return lastTitle.title }
49+
let title = build(model, height: height, dark: dark)
50+
lastTitle = (signature, title)
51+
return title
52+
}
53+
54+
private static func build(_ model: StatusItemModel, height: CGFloat, dark: Bool) -> NSAttributedString {
4355
let title = NSMutableAttributedString()
4456
for (index, cell) in model.cells.enumerated() {
4557
if index > 0 { title.append(attachment(separatorImage(height: height))) }
@@ -71,14 +83,14 @@ public enum StatusItemRenderer {
7183

7284
static func textImage(_ cell: StatusCell, height: CGFloat, dark: Bool) -> NSImage {
7385
let lines = lineStrings(cell, height: height, dark: dark)
74-
let widest = lines.map { $0.size().width }.max() ?? 0
75-
let width = ceil(widest) + cellPadding * 2
76-
let heights = lines.map { $0.size().height }
77-
let total = heights.reduce(0, +)
86+
// Laying out an attributed string is the expensive part here, and AppKit calls the drawing handler again on
87+
// every scale and appearance change, so measure once and carry the sizes in.
88+
let sizes = lines.map { $0.size() }
89+
let width = ceil(sizes.map(\.width).max() ?? 0) + cellPadding * 2
90+
let total = sizes.map(\.height).reduce(0, +)
7891
return NSImage(size: CGSize(width: width, height: height), flipped: true) { rect in
7992
var lineTop = (rect.height - total) / 2
80-
for line in lines {
81-
let size = line.size()
93+
for (line, size) in zip(lines, sizes) {
8294
line.draw(at: CGPoint(x: (rect.width - size.width) / 2, y: lineTop))
8395
lineTop += size.height
8496
}

Sources/TokenMenuBarUI/UIActions.swift

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -111,14 +111,16 @@ public final class UIEnvironment {
111111
}
112112

113113
public func loadRecentSamples() async {
114+
// Only the popover's cards read these, and one query covers every window: a loop was an actor hop and a
115+
// prepared statement each.
116+
guard state.popoverVisible else { return }
114117
let since = now.addingTimeInterval(-PaceEstimate.slopeWindow)
115-
var loaded: [WindowKey: [UsageSample]] = [:]
116-
for (provider, item) in state.providers {
117-
for window in item.snapshot?.windows ?? [] {
118-
let key = WindowKey(provider, window)
119-
loaded[key] = (try? await history.recentSamples(key: key, since: since)) ?? []
120-
}
118+
let keys = state.providers.flatMap { provider, item in
119+
(item.snapshot?.windows ?? []).map { WindowKey(provider, $0) }
121120
}
121+
let rows = (try? await history.samples(keys: keys, from: since, to: .distantFuture)) ?? []
122+
var loaded = Dictionary(grouping: rows, by: \.key)
123+
for key in keys where loaded[key] == nil { loaded[key] = [] }
122124
samples = loaded
123125
}
124126

Tests/TokenMenuBarUITests/ViewTests.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ import Testing
2323
#expect(environment.settings.lastTab == .history)
2424
environment.tick()
2525
#expect(environment.now == fixedNow)
26+
// The cards are the only reader, so nothing is fetched while the popover is closed.
27+
await environment.loadRecentSamples()
28+
#expect(environment.samples.isEmpty)
29+
environment.state.popoverVisible = true
2630
await environment.loadRecentSamples()
2731
#expect(environment.samples.count == 6)
2832
#expect(environment.cards.count == 2)

0 commit comments

Comments
 (0)