perf(watch-history): stop the background poll freezing the main thread

The 8s watch-history poll ran refresh() on the main actor, which read and
JSON-decoded the unioned watch log THREE times (playedPaths, resumePositions,
episodeProgress each re-read) and called MediaPaths.toRemote() per event — and
every toRemote rebuilt ProcessInfo.environment (~22µs each, the whole env dict
is reconstructed on every access) plus a homeDirectory lookup. A live sample
caught the main thread 100% in this path; the app sat at 78–113% CPU.

- Cache MediaPaths.remoteRoot / mappings (process-constant) → kills the
  per-call env-dictionary rebuild storm.
- WatchHistory.derivedState(): read+decode the log ONCE, feed all three
  derived computations → 3× fewer reads/decodes per refresh.
- WatchHistoryController.refreshAsync(): the background poll now parses off the
  main thread on a utility task and only assigns the small results on main.

Settled CPU drops from ~78% sustained to ~0% idle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-30 01:44:06 -04:00
parent e532fe14bc
commit ee3da4e101
2 changed files with 62 additions and 13 deletions

View file

@ -48,12 +48,29 @@ public enum WatchHistory {
return f return f
} }
/// Read+decode the unioned watch logs **once** and compute every derived set the
/// controller needs. Returns only public result types (so it crosses the type
/// boundary to `WatchHistoryController`); the `[WatchEvent]` array stays private
/// inside this enum. Replaces three separate read+decode passes per refresh.
static func derivedState() -> (played: Set<String>,
resume: [String: Double],
episodes: [String: EpisodeProgress]) {
let events = readWatchlog()
return (playedPaths(from: events),
resumePositions(from: events),
episodeProgress(from: events))
}
public static func continueItems(limit: Int = 24) -> [ContinueItem] { public static func continueItems(limit: Int = 24) -> [ContinueItem] {
continueItems(from: readWatchlog(), limit: limit)
}
private static func continueItems(from events: [WatchEvent], limit: Int = 24) -> [ContinueItem] {
var byKey: [String: ContinueItem] = [:] var byKey: [String: ContinueItem] = [:]
let iso = ISO8601DateFormatter() let iso = ISO8601DateFormatter()
let isoFrac = fractionalFormatter() let isoFrac = fractionalFormatter()
for ev in readWatchlog() { for ev in events {
let when = parseTS(ev.ts, iso, isoFrac) let when = parseTS(ev.ts, iso, isoFrac)
let item = ContinueItem( let item = ContinueItem(
title: ev.label.isEmpty ? ev.show : ev.label, title: ev.label.isEmpty ? ev.show : ev.label,
@ -75,8 +92,12 @@ public enum WatchHistory {
} }
public static func resumePositions() -> [String: Double] { public static func resumePositions() -> [String: Double] {
resumePositions(from: readWatchlog())
}
private static func resumePositions(from events: [WatchEvent]) -> [String: Double] {
var out: [String: Double] = [:] var out: [String: Double] = [:]
for item in continueItems(limit: 2000) { for item in continueItems(from: events, limit: 2000) {
if let p = item.positionSeconds, p > 1 { out[MediaPaths.toRemote(item.path)] = p } if let p = item.positionSeconds, p > 1 { out[MediaPaths.toRemote(item.path)] = p }
} }
return out return out
@ -99,10 +120,14 @@ public enum WatchHistory {
} }
public static func episodeProgress() -> [String: EpisodeProgress] { public static func episodeProgress() -> [String: EpisodeProgress] {
episodeProgress(from: readWatchlog())
}
private static func episodeProgress(from events: [WatchEvent]) -> [String: EpisodeProgress] {
var best: [String: WatchEvent] = [:] var best: [String: WatchEvent] = [:]
let iso = ISO8601DateFormatter() let iso = ISO8601DateFormatter()
let isoFrac = fractionalFormatter() let isoFrac = fractionalFormatter()
for ev in readWatchlog() where isRealVideo(ev.path) { for ev in events where isRealVideo(ev.path) {
guard let pos = ev.resumeSeconds, pos >= 0 else { continue } guard let pos = ev.resumeSeconds, pos >= 0 else { continue }
let key = MediaPaths.toRemote(ev.path) let key = MediaPaths.toRemote(ev.path)
if let prev = best[key] { if let prev = best[key] {
@ -122,8 +147,11 @@ public enum WatchHistory {
} }
public static func playedPaths() -> Set<String> { public static func playedPaths() -> Set<String> {
playedPaths(from: readWatchlog())
}
private static func playedPaths(from events: [WatchEvent]) -> Set<String> {
var out = Set<String>() var out = Set<String>()
let events = readWatchlog()
// Compute last reset per show so a rewatch clears the "started" set for // Compute last reset per show so a rewatch clears the "started" set for
// badges and nextUnwatched while preserving append-only history. // badges and nextUnwatched while preserving append-only history.
var lastResetByShow: [String: Date] = [:] var lastResetByShow: [String: Date] = [:]
@ -332,11 +360,28 @@ public final class WatchHistoryController {
} }
/// Re-parse the unioned watch logs (plum + black mirror) and recompute all derived sets. /// Re-parse the unioned watch logs (plum + black mirror) and recompute all derived sets.
/// Cheap; called after our own appends and on the background poll to catch externals. /// Reads+decodes the log **once** and feeds it to every derived computation (the old
/// path read and JSON-decoded the whole log three times). Synchronous used right
/// after our own appends so callers see fresh state immediately; the background poll
/// uses `refreshAsync()` instead to keep this work off the main thread.
public func refresh() { public func refresh() {
playedPaths = WatchHistory.playedPaths() let d = WatchHistory.derivedState()
resumePositions = WatchHistory.resumePositions() playedPaths = d.played
episodeProgress = WatchHistory.episodeProgress() resumePositions = d.resume
episodeProgress = d.episodes
lastRefresh = Date()
}
/// Off-main refresh for the background poll: the read+decode+derive runs on a
/// utility thread, and only the (small) result assignment happens on the main
/// actor. This is what stops the 8-second poll from periodically freezing the UI.
public func refreshAsync() async {
let derived = await Task.detached(priority: .utility) {
WatchHistory.derivedState()
}.value
playedPaths = derived.played
resumePositions = derived.resume
episodeProgress = derived.episodes
lastRefresh = Date() lastRefresh = Date()
} }
@ -390,7 +435,7 @@ public final class WatchHistoryController {
pollTask?.cancel() pollTask?.cancel()
pollTask = Task { [weak self] in pollTask = Task { [weak self] in
while !Task.isCancelled { while !Task.isCancelled {
self?.refresh() await self?.refreshAsync()
// Occasional black sync (the call itself throttles internally) // Occasional black sync (the call itself throttles internally)
_ = await self?.syncBlack() _ = await self?.syncBlack()
try? await Task.sleep(for: .seconds(8)) try? await Task.sleep(for: .seconds(8))

View file

@ -12,21 +12,25 @@ import Foundation
/// (it's the identity on paths that are already storage-side). /// (it's the identity on paths that are already storage-side).
public enum MediaPaths { public enum MediaPaths {
/// Storage-side media root (env-overridable, matches the TS bridge default). /// Storage-side media root (env-overridable, matches the TS bridge default).
public static var remoteRoot: String { /// Cached once: the environment is fixed for the process lifetime, and
/// `ProcessInfo.environment` rebuilds the entire env dictionary on every access
/// (~22µs), which `toRemote` would otherwise pay thousands of times per watch-log
/// refresh. See the watch-history poll hot path.
public static let remoteRoot: String =
ProcessInfo.processInfo.environment["BLACK_MEDIA_ROOT"] ?? "/bigdisk/_/media" ProcessInfo.processInfo.environment["BLACK_MEDIA_ROOT"] ?? "/bigdisk/_/media"
}
/// Legacy laptop mount prefix storage-side absolute prefix. Longest first so /// Legacy laptop mount prefix storage-side absolute prefix. Longest first so
/// `~/_/bigdisk/_/media` wins over `~/_/bigdisk`. Only relevant for stale paths /// `~/_/bigdisk/_/media` wins over `~/_/bigdisk`. Only relevant for stale paths
/// persisted before the mount was dropped fresh scans are already storage-side. /// persisted before the mount was dropped fresh scans are already storage-side.
private static var mappings: [(plum: String, remote: String)] { /// Cached once (the home dir and root are process-constant) for the same reason.
private static let mappings: [(plum: String, remote: String)] = {
let home = FileManager.default.homeDirectoryForCurrentUser.path let home = FileManager.default.homeDirectoryForCurrentUser.path
return [ return [
(home + "/_/bigdisk/_/media", remoteRoot), (home + "/_/bigdisk/_/media", remoteRoot),
(home + "/media", remoteRoot), (home + "/media", remoteRoot),
(home + "/_/bigdisk", "/bigdisk"), (home + "/_/bigdisk", "/bigdisk"),
] ]
} }()
/// Normalize any path to its storage-side absolute form. Already-canonical paths and /// Normalize any path to its storage-side absolute form. Already-canonical paths and
/// anything we don't manage pass through unchanged (so it's the identity on the /// anything we don't manage pass through unchanged (so it's the identity on the