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:
Kenny Lopez
2026-08-17 15:54:00 +01:00
co-authored by Princess Donut
parent ee248a32ff
commit 50b0ab9a01
2 changed files with 242 additions and 39 deletions
+143 -39
View File
@@ -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
}
}
+99
View File
@@ -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()
}
}
}