Files
punktfunk/clients/apple/Sources/PunktfunkKit/Connection/ArtCache.swift
T
enricobuehler 7798401f06
ci / rust-arm64 (pull_request) Successful in 1m36s
ci / bun-nix (pull_request) Successful in 24s
ci / web (pull_request) Successful in 1m16s
apple / swift (pull_request) Successful in 1m38s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m12s
ci / rust (pull_request) Successful in 13m3s
perf(apple): cache posters on disk and pool the mgmt connections
Moving the management API onto Network.framework left one request per
connection, so a library grid paid a TLS handshake per poster where the pooled
URLSession had shared one. And the Apple client -- unlike Windows -- never
cached art at all, so it re-fetched every poster on every visit.

ArtCache: a size- and age-bounded blob cache in the CACHES directory (every byte
is re-derivable from the host, so the system is welcome to evict it). Keyed by
the SHA-256 of the absolute URL, so host-proxy paths and store CDN URLs share
one cache without colliding. Reads touch the entry, so eviction is by last USE,
not last write. Empty bodies and data: URLs are refused -- neither is worth a
file. Defaults: 128 MB, 30 days.

Connection pooling: MgmtConnectionPool keeps up to four keep-alive connections
per host and makes further callers wait rather than opening more, which is the
part that matters -- a grid can ask for dozens of posters at once. A connection
the host dropped since we last used it is indistinguishable from a live one
until we write, so a REUSED connection that fails is retried once on a fresh
one; a fresh failure is a real failure.

Keep-alive means a response can no longer be delimited by the peer hanging up,
so HTTPResponseParser.messageLength finds the end from the framing itself --
Content-Length or the chunked terminal chunk plus trailers. Getting that wrong
would truncate a response or bleed one into the next, silently, so it carries
the bulk of the new tests. A connection with bytes left over after a response is
dropped rather than reused: we never pipeline, so anything trailing means we are
out of sync.

LibraryView closes the loader's pooled connections on disappear instead of
leaving sockets open on a screen the user has left.

16 new tests: message framing (both encodings, partial reads, back-to-back
responses, close detection) and the cache (binary round trip, key separation,
refusals, expiry, LRU eviction).
2026-08-08 01:09:25 +02:00

117 lines
5.0 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// On-disk cache for library cover art.
//
// Posters are the bulk of what the library screen transfers and they essentially never change, so
// re-fetching them on every visit is pure waste — the Windows client has cached them on disk for
// this reason and Apple did not. It matters more now that host art rides `MgmtTransport`: a cache
// hit costs no connection at all.
//
// Lives in the CACHES directory on purpose: every byte here is re-derivable from the host, so the
// system is welcome to evict it under storage pressure. Entries are keyed by the SHA-256 of the
// absolute URL, which covers both host-proxy paths and store CDN URLs without either colliding.
//
// Deliberately free of any Network.framework / PunktfunkCore dependency, so it can be unit-tested
// against a temporary directory.
import CryptoKit
import Foundation
/// A size- and age-bounded blob cache. An actor so disk work stays off whichever thread the
/// SwiftUI poster view happens to be on, and so pruning can never race a write.
actor ArtCache {
private let directory: URL
private let maxBytes: Int
private let maxAge: TimeInterval
private let fileManager = FileManager.default
/// `directory` is created on demand. Defaults: 128 MB — a 200-title library of 600×900
/// capsules lands far under that — and 30 days, which only matters for art a host later
/// replaces.
init(directory: URL, maxBytes: Int = 128 * 1024 * 1024, maxAge: TimeInterval = 30 * 24 * 3600) {
self.directory = directory
self.maxBytes = maxBytes
self.maxAge = maxAge
}
/// The app's standard location, or nil if the caches directory is unavailable (in which case
/// callers simply run without a cache rather than failing).
static func standard() -> ArtCache? {
guard let caches = FileManager.default.urls(
for: .cachesDirectory, in: .userDomainMask).first
else { return nil }
return ArtCache(directory: caches.appendingPathComponent("PunktfunkArt", isDirectory: true))
}
func data(for url: URL) -> Data? {
let file = path(for: url)
guard let data = try? Data(contentsOf: file) else { return nil }
// Age out stale art rather than serving it forever.
if let modified = modificationDate(of: file), Date().timeIntervalSince(modified) > maxAge {
try? fileManager.removeItem(at: file)
return nil
}
// Touch, so eviction can order by last USE rather than last write.
try? fileManager.setAttributes([.modificationDate: Date()], ofItemAtPath: file.path)
return data
}
func store(_ data: Data, for url: URL) {
// An empty body is not art, and a `data:` URL is already inline — caching either is a
// pure loss.
guard !data.isEmpty, url.scheme?.lowercased() != "data" else { return }
do {
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
try data.write(to: path(for: url), options: .atomic)
} catch {
return // a cache that can't write is a slow cache, not a broken app
}
prune()
}
/// Drop the oldest entries until the directory fits the budget. Also removes anything past
/// `maxAge` so a cache that is under budget still doesn't hoard stale art forever.
func prune() {
let keys: [URLResourceKey] = [.contentModificationDateKey, .fileSizeKey]
guard let entries = try? fileManager.contentsOfDirectory(
at: directory, includingPropertiesForKeys: keys, options: .skipsHiddenFiles)
else { return }
var files: [(url: URL, date: Date, size: Int)] = []
var total = 0
let now = Date()
for entry in entries {
let values = try? entry.resourceValues(forKeys: Set(keys))
let date = values?.contentModificationDate ?? .distantPast
let size = values?.fileSize ?? 0
if now.timeIntervalSince(date) > maxAge {
try? fileManager.removeItem(at: entry)
continue
}
files.append((entry, date, size))
total += size
}
guard total > maxBytes else { return }
// Oldest first — `data(for:)` touches on read, so this is least-recently-USED.
for file in files.sorted(by: { $0.date < $1.date }) {
guard total > maxBytes else { break }
try? fileManager.removeItem(at: file.url)
total -= file.size
}
}
/// Wipe the cache — for a "clear cached data" affordance, and for tests.
func clear() {
try? fileManager.removeItem(at: directory)
}
private func path(for url: URL) -> URL {
let digest = SHA256.hash(data: Data(url.absoluteString.utf8))
let name = digest.map { String(format: "%02x", $0) }.joined()
return directory.appendingPathComponent(name, isDirectory: false)
}
private func modificationDate(of file: URL) -> Date? {
(try? file.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate
}
}