From 50b0ab9a01dc3632954ecb17bc59cdd4a5d03e15 Mon Sep 17 00:00:00 2001 From: Kenny Lopez Date: Mon, 17 Aug 2026 15:54:00 +0100 Subject: [PATCH] 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 Co-authored-by: Princess Donut Signed-off-by: Kenny Lopez --- mobile/ios/Runner/NativeEmojiPickerView.swift | 182 ++++++++++++++---- mobile/ios/RunnerTests/RunnerTests.swift | 99 ++++++++++ 2 files changed, 242 insertions(+), 39 deletions(-) diff --git a/mobile/ios/Runner/NativeEmojiPickerView.swift b/mobile/ios/Runner/NativeEmojiPickerView.swift index 45713940b..610eb1a86 100644 --- a/mobile/ios/Runner/NativeEmojiPickerView.swift +++ b/mobile/ios/Runner/NativeEmojiPickerView.swift @@ -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 + } + + private let maximumConcurrentDownloads: Int + private let downloader: Downloader + private let cache = NSCache() + 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) 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 } } diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index 5f3b45f36..f856ae4ff 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -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 + } + + private var active = 0 + private var peakActive = 0 + private var started = 0 + private var releaseContinuations: [CheckedContinuation] = [] + 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() + } + } +}