mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Bound concurrent native emoji downloads
Route custom emoji thumbnails through a shared actor that limits active network transfers to four and keeps decoded thumbnails in an 8 MiB cost-bounded cache. Queued requests remain cancellation-aware, while the existing per-response byte limit and downsampling protections stay intact. Add an iOS regression that holds eight distinct requests and proves no more than the configured number can download at once. This changes no picker UI or interaction behavior. Co-authored-by: Kenny Lopez <klopez4212@gmail.com> Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz> Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
This commit is contained in:
co-authored by
Princess Donut
parent
ee248a32ff
commit
50b0ab9a01
@@ -355,8 +355,6 @@ struct NativeEmojiRemoteImage: View {
|
||||
let fallbackColor: UIColor
|
||||
|
||||
@State private var phase: Phase = .loading
|
||||
private static let maxDownloadBytes = 10 * 1024 * 1024
|
||||
private static let maxThumbnailPixels = 84
|
||||
|
||||
private enum Phase {
|
||||
case loading
|
||||
@@ -385,46 +383,148 @@ struct NativeEmojiRemoteImage: View {
|
||||
for (name, value) in requestHeaders {
|
||||
request.setValue(value, forHTTPHeaderField: name)
|
||||
}
|
||||
let (bytes, response) = try await URLSession.shared.bytes(for: request)
|
||||
guard
|
||||
let httpResponse = response as? HTTPURLResponse,
|
||||
(200..<300).contains(httpResponse.statusCode)
|
||||
else {
|
||||
phase = .failure
|
||||
return
|
||||
}
|
||||
if let contentLength = httpResponse.value(forHTTPHeaderField: "Content-Length"),
|
||||
let byteCount = Int(contentLength),
|
||||
byteCount > Self.maxDownloadBytes
|
||||
{
|
||||
phase = .failure
|
||||
return
|
||||
}
|
||||
var data = Data()
|
||||
let expected = httpResponse.expectedContentLength
|
||||
if expected > 0 {
|
||||
data.reserveCapacity(
|
||||
Int(min(expected, Int64(Self.maxDownloadBytes)))
|
||||
)
|
||||
}
|
||||
for try await byte in bytes {
|
||||
guard data.count < Self.maxDownloadBytes else {
|
||||
phase = .failure
|
||||
return
|
||||
}
|
||||
data.append(byte)
|
||||
}
|
||||
guard let image = Self.thumbnail(from: data) else {
|
||||
phase = .failure
|
||||
return
|
||||
}
|
||||
phase = .success(image)
|
||||
phase = .success(
|
||||
try await NativeEmojiRemoteImageLoader.shared.image(for: request)
|
||||
)
|
||||
} catch {
|
||||
if !Task.isCancelled { phase = .failure }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var requestIdentity: String {
|
||||
url.absoluteString
|
||||
}
|
||||
}
|
||||
|
||||
enum NativeEmojiRemoteImageError: Error {
|
||||
case invalidResponse
|
||||
case responseTooLarge
|
||||
case invalidImage
|
||||
}
|
||||
|
||||
actor NativeEmojiRemoteImageLoader {
|
||||
typealias Downloader = (URLRequest) async throws -> UIImage
|
||||
|
||||
static let shared = NativeEmojiRemoteImageLoader()
|
||||
static let defaultMaximumConcurrentDownloads = 4
|
||||
|
||||
private static let maximumDownloadBytes = 10 * 1024 * 1024
|
||||
private static let maximumThumbnailPixels = 84
|
||||
private static let defaultCacheByteLimit = 8 * 1024 * 1024
|
||||
|
||||
private struct Waiter {
|
||||
let id: UUID
|
||||
let continuation: CheckedContinuation<Void, Error>
|
||||
}
|
||||
|
||||
private let maximumConcurrentDownloads: Int
|
||||
private let downloader: Downloader
|
||||
private let cache = NSCache<NSURLRequest, UIImage>()
|
||||
private var activeDownloadCount = 0
|
||||
private var waiters: [Waiter] = []
|
||||
|
||||
init(
|
||||
maximumConcurrentDownloads: Int = defaultMaximumConcurrentDownloads,
|
||||
cacheByteLimit: Int = defaultCacheByteLimit,
|
||||
downloader: @escaping Downloader = NativeEmojiRemoteImageLoader.download
|
||||
) {
|
||||
precondition(maximumConcurrentDownloads > 0)
|
||||
precondition(cacheByteLimit >= 0)
|
||||
self.maximumConcurrentDownloads = maximumConcurrentDownloads
|
||||
self.downloader = downloader
|
||||
cache.totalCostLimit = cacheByteLimit
|
||||
}
|
||||
|
||||
func image(for request: URLRequest) async throws -> UIImage {
|
||||
let cacheKey = request as NSURLRequest
|
||||
if let cached = cache.object(forKey: cacheKey) {
|
||||
return cached
|
||||
}
|
||||
|
||||
try await acquireDownloadSlot()
|
||||
defer { releaseDownloadSlot() }
|
||||
|
||||
try Task.checkCancellation()
|
||||
if let cached = cache.object(forKey: cacheKey) {
|
||||
return cached
|
||||
}
|
||||
|
||||
let image = try await downloader(request)
|
||||
cache.setObject(image, forKey: cacheKey, cost: Self.cacheCost(for: image))
|
||||
return image
|
||||
}
|
||||
|
||||
private func acquireDownloadSlot() async throws {
|
||||
try Task.checkCancellation()
|
||||
guard activeDownloadCount >= maximumConcurrentDownloads else {
|
||||
activeDownloadCount += 1
|
||||
return
|
||||
}
|
||||
|
||||
let waiterID = UUID()
|
||||
try await withTaskCancellationHandler {
|
||||
try await withCheckedThrowingContinuation {
|
||||
(continuation: CheckedContinuation<Void, Error>) in
|
||||
if Task.isCancelled {
|
||||
continuation.resume(throwing: CancellationError())
|
||||
} else {
|
||||
waiters.append(Waiter(id: waiterID, continuation: continuation))
|
||||
}
|
||||
}
|
||||
} onCancel: {
|
||||
Task { await self.cancelWaiter(id: waiterID) }
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelWaiter(id: UUID) {
|
||||
guard let index = waiters.firstIndex(where: { $0.id == id }) else { return }
|
||||
let waiter = waiters.remove(at: index)
|
||||
waiter.continuation.resume(throwing: CancellationError())
|
||||
}
|
||||
|
||||
private func releaseDownloadSlot() {
|
||||
while !waiters.isEmpty {
|
||||
let waiter = waiters.removeFirst()
|
||||
waiter.continuation.resume()
|
||||
return
|
||||
}
|
||||
activeDownloadCount -= 1
|
||||
}
|
||||
|
||||
private static func download(_ request: URLRequest) async throws -> UIImage {
|
||||
let (bytes, response) = try await URLSession.shared.bytes(for: request)
|
||||
guard
|
||||
let httpResponse = response as? HTTPURLResponse,
|
||||
(200..<300).contains(httpResponse.statusCode)
|
||||
else {
|
||||
throw NativeEmojiRemoteImageError.invalidResponse
|
||||
}
|
||||
if let contentLength = httpResponse.value(forHTTPHeaderField: "Content-Length"),
|
||||
let byteCount = Int(contentLength),
|
||||
byteCount > maximumDownloadBytes
|
||||
{
|
||||
throw NativeEmojiRemoteImageError.responseTooLarge
|
||||
}
|
||||
|
||||
var data = Data()
|
||||
let expected = httpResponse.expectedContentLength
|
||||
if expected > 0 {
|
||||
data.reserveCapacity(Int(min(expected, Int64(maximumDownloadBytes))))
|
||||
}
|
||||
for try await byte in bytes {
|
||||
guard data.count < maximumDownloadBytes else {
|
||||
throw NativeEmojiRemoteImageError.responseTooLarge
|
||||
}
|
||||
data.append(byte)
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
guard let image = thumbnail(from: data) else {
|
||||
throw NativeEmojiRemoteImageError.invalidImage
|
||||
}
|
||||
return image
|
||||
}
|
||||
|
||||
private static func thumbnail(from data: Data) -> UIImage? {
|
||||
guard let source = CGImageSourceCreateWithData(data as CFData, nil) else {
|
||||
return nil
|
||||
@@ -432,7 +532,7 @@ struct NativeEmojiRemoteImage: View {
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||
kCGImageSourceThumbnailMaxPixelSize: maxThumbnailPixels,
|
||||
kCGImageSourceThumbnailMaxPixelSize: maximumThumbnailPixels,
|
||||
kCGImageSourceShouldCacheImmediately: true,
|
||||
]
|
||||
guard
|
||||
@@ -447,7 +547,11 @@ struct NativeEmojiRemoteImage: View {
|
||||
return UIImage(cgImage: image)
|
||||
}
|
||||
|
||||
private var requestIdentity: String {
|
||||
url.absoluteString
|
||||
private static func cacheCost(for image: UIImage) -> Int {
|
||||
guard let cgImage = image.cgImage else { return 0 }
|
||||
let (cost, overflow) = cgImage.bytesPerRow.multipliedReportingOverflow(
|
||||
by: cgImage.height
|
||||
)
|
||||
return overflow ? Int.max : cost
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,6 +456,47 @@ class RunnerTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
func testRemoteEmojiLoaderLimitsConcurrentDownloads() async throws {
|
||||
let maximumConcurrentDownloads = 3
|
||||
let probe = NativeEmojiDownloadProbe()
|
||||
let loader = NativeEmojiRemoteImageLoader(
|
||||
maximumConcurrentDownloads: maximumConcurrentDownloads,
|
||||
cacheByteLimit: 0
|
||||
) { _ in
|
||||
await probe.holdDownload()
|
||||
return UIImage()
|
||||
}
|
||||
let tasks = (0..<8).map { index in
|
||||
Task {
|
||||
try await loader.image(
|
||||
for: URLRequest(
|
||||
url: try XCTUnwrap(URL(string: "https://example.com/\(index).png"))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await probe.waitUntilStarted(maximumConcurrentDownloads)
|
||||
try await Task.sleep(nanoseconds: 50_000_000)
|
||||
var snapshot = await probe.snapshot()
|
||||
XCTAssertEqual(snapshot.started, maximumConcurrentDownloads)
|
||||
XCTAssertEqual(snapshot.peakActive, maximumConcurrentDownloads)
|
||||
|
||||
for expectedStarted in (maximumConcurrentDownloads + 1)...tasks.count {
|
||||
await probe.releaseOne()
|
||||
await probe.waitUntilStarted(expectedStarted)
|
||||
}
|
||||
await probe.releaseAll()
|
||||
for task in tasks {
|
||||
_ = try await task.value
|
||||
}
|
||||
|
||||
snapshot = await probe.snapshot()
|
||||
XCTAssertEqual(snapshot.started, tasks.count)
|
||||
XCTAssertEqual(snapshot.peakActive, maximumConcurrentDownloads)
|
||||
XCTAssertEqual(snapshot.active, 0)
|
||||
}
|
||||
|
||||
private func displayP3Image(red: CGFloat, green: CGFloat, blue: CGFloat) throws -> UIImage {
|
||||
let colorSpace = try XCTUnwrap(CGColorSpace(name: CGColorSpace.displayP3))
|
||||
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
|
||||
@@ -683,3 +724,61 @@ private func readUInt32BigEndian(_ data: Data, at offset: Int) throws -> UInt32
|
||||
return UInt32(data[offset]) << 24 | UInt32(data[offset + 1]) << 16
|
||||
| UInt32(data[offset + 2]) << 8 | UInt32(data[offset + 3])
|
||||
}
|
||||
|
||||
private actor NativeEmojiDownloadProbe {
|
||||
private struct MilestoneWaiter {
|
||||
let count: Int
|
||||
let continuation: CheckedContinuation<Void, Never>
|
||||
}
|
||||
|
||||
private var active = 0
|
||||
private var peakActive = 0
|
||||
private var started = 0
|
||||
private var releaseContinuations: [CheckedContinuation<Void, Never>] = []
|
||||
private var milestoneWaiters: [MilestoneWaiter] = []
|
||||
|
||||
func holdDownload() async {
|
||||
active += 1
|
||||
started += 1
|
||||
peakActive = max(peakActive, active)
|
||||
resumeReachedMilestones()
|
||||
await withCheckedContinuation { continuation in
|
||||
releaseContinuations.append(continuation)
|
||||
}
|
||||
active -= 1
|
||||
}
|
||||
|
||||
func waitUntilStarted(_ count: Int) async {
|
||||
guard started < count else { return }
|
||||
await withCheckedContinuation { continuation in
|
||||
milestoneWaiters.append(
|
||||
MilestoneWaiter(count: count, continuation: continuation)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func releaseOne() {
|
||||
guard !releaseContinuations.isEmpty else { return }
|
||||
releaseContinuations.removeFirst().resume()
|
||||
}
|
||||
|
||||
func releaseAll() {
|
||||
let continuations = releaseContinuations
|
||||
releaseContinuations.removeAll()
|
||||
for continuation in continuations {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
func snapshot() -> (active: Int, peakActive: Int, started: Int) {
|
||||
(active, peakActive, started)
|
||||
}
|
||||
|
||||
private func resumeReachedMilestones() {
|
||||
let reached = milestoneWaiters.filter { $0.count <= started }
|
||||
milestoneWaiters.removeAll { $0.count <= started }
|
||||
for waiter in reached {
|
||||
waiter.continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user