From 8328ee55c322f7ddccd16f76595cf2ecf3d34050 Mon Sep 17 00:00:00 2001 From: phranck Date: Fri, 24 Jul 2026 15:57:35 +0200 Subject: [PATCH 1/4] Feat: Add async URLImageCoordinator with dedup and injectable transport - Add the URLImageDataTransport protocol (internal) with a default URLSession-backed implementation that uses async data(for:) instead of a DispatchSemaphore + nonisolated(unsafe) result variable - Add the URLImageCoordinator actor: cache hits skip the transport, concurrent identical URLs share one in-flight task, cancellation routes into the in-flight task, non-2xx responses fail with downloadFailed, and byte limits reject before decoding - Cover cache hit/miss, deduplication, status validation, byte-limit rejection, and cancellation forwarding with a scripted recording transport that observes fetch cancellations --- Sources/TUIkitImage/URLImageCoordinator.swift | 203 ++++++++++++++++ .../RecordingURLTransport.swift | 106 ++++++++ .../URLImageCoordinatorTests.swift | 230 ++++++++++++++++++ 3 files changed, 539 insertions(+) create mode 100644 Sources/TUIkitImage/URLImageCoordinator.swift create mode 100644 Tests/TUIkitImageTests/RecordingURLTransport.swift create mode 100644 Tests/TUIkitImageTests/URLImageCoordinatorTests.swift diff --git a/Sources/TUIkitImage/URLImageCoordinator.swift b/Sources/TUIkitImage/URLImageCoordinator.swift new file mode 100644 index 000000000..fed67b466 --- /dev/null +++ b/Sources/TUIkitImage/URLImageCoordinator.swift @@ -0,0 +1,203 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// URLImageCoordinator.swift +// +// Created by LAYERED.work +// License: MIT + +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +// MARK: - URL Image Data Transport + +/// Fetches raw bytes for a URL request. +/// +/// Split out from the coordinator so tests can inject scripted responses +/// without touching the network. Production uses `URLSessionImageTransport`. +protocol URLImageDataTransport: Sendable { + /// Fetches the response bytes for the given request. + /// + /// - Parameter request: The request to perform. `timeoutInterval` + /// already reflects the caller's timeout. + /// - Returns: The response bytes and the HTTP metadata. + /// - Throws: Any error the transport encountered (surfaces cancellation + /// through `CancellationError` or `URLError.cancelled`). + func fetch(request: URLRequest) async throws -> (Data, URLResponse) +} + +/// Default transport backed by `URLSession.data(for:)`. +/// +/// Uses one ephemeral session per instance so cookies and disk caches do +/// not bleed across runtimes. +struct URLSessionImageTransport: URLImageDataTransport { + /// The session performing the requests. + private let session: URLSession + + /// Creates a transport with the given session configuration. + init(configuration: URLSessionConfiguration = .ephemeral) { + self.session = URLSession(configuration: configuration) + } + + func fetch(request: URLRequest) async throws -> (Data, URLResponse) { + try await session.data(for: request) + } +} + +// MARK: - URL Image Coordinator + +/// Coordinates URL image loads: deduplicates identical in-flight requests, +/// enforces byte and status limits, and delegates decoding to the shared +/// pure Swift decoder. +/// +/// Actor isolation guarantees that dedup bookkeeping and coordinator state +/// stay consistent under concurrent load without semaphores or unsafe +/// cross-actor result variables. +actor URLImageCoordinator { + /// One in-flight download shared between deduplicated callers. + private struct InFlight { + let task: Task + } + + /// The transport performing the actual network requests. + private let transport: any URLImageDataTransport + + /// In-flight downloads keyed by their URL string. + private var inFlight: [String: InFlight] = [:] + + /// Creates a coordinator with the given transport. + /// + /// - Parameter transport: The transport that fetches URL bytes. + /// Defaults to `URLSessionImageTransport`. + init(transport: any URLImageDataTransport = URLSessionImageTransport()) { + self.transport = transport + } + + /// Loads and decodes an image for the given URL. + /// + /// Cache hits skip the transport. Concurrent cache-missing loads for + /// the same URL share a single transport call. A cancelled caller + /// cancels the shared task only when no other caller is still waiting. + /// + /// - Parameters: + /// - urlString: The URL to load. + /// - cache: The runtime-owned image cache. + /// - timeout: The download timeout in seconds. + /// - maxPixelCount: An optional additional per-image pixel limit. + /// - decoder: The decoder used to convert bytes to RGBA. + /// - Returns: The decoded image. + /// - Throws: `ImageLoadError` on rejection, `CancellationError` / + /// `URLError.cancelled` on cancellation. + func load( + _ urlString: String, + cache: URLImageCache, + timeout: TimeInterval, + maxPixelCount: Int?, + decoder: PureSwiftImageDecoder + ) async throws -> RGBAImage { + if let cached = cache.get(urlString) { + try decoder.validateCachedImage(cached, maxPixelCount: maxPixelCount) + return cached + } + + if let existing = inFlight[urlString] { + return try await Self.awaitTaskRespectingCancellation(existing.task) + } + + let transport = self.transport + let task = Task { + try await Self.perform( + urlString: urlString, + transport: transport, + cache: cache, + timeout: timeout, + maxPixelCount: maxPixelCount, + decoder: decoder + ) + } + inFlight[urlString] = InFlight(task: task) + + do { + let image = try await Self.awaitTaskRespectingCancellation(task) + inFlight.removeValue(forKey: urlString) + return image + } catch { + inFlight.removeValue(forKey: urlString) + throw error + } + } + + /// Awaits a task's value and forwards caller cancellation to it. + /// + /// The shared in-flight task cannot observe individual callers; this + /// helper routes each caller's cancellation into the task so a + /// cancelled caller sees `CancellationError` or `URLError.cancelled` + /// exactly like a non-deduplicated load. + private static func awaitTaskRespectingCancellation( + _ task: Task + ) async throws -> T { + try await withTaskCancellationHandler { + try await task.value + } onCancel: { + task.cancel() + } + } + + // MARK: - Fetch + Validate + + /// Performs the transport call and validation outside the actor's + /// isolation so a slow download does not stall other coordinator work. + private static func perform( + urlString: String, + transport: any URLImageDataTransport, + cache: URLImageCache, + timeout: TimeInterval, + maxPixelCount: Int?, + decoder: PureSwiftImageDecoder + ) async throws -> RGBAImage { + guard let url = URL(string: urlString) else { + throw ImageLoadError.downloadFailed("Invalid URL: \(urlString)") + } + + var request = URLRequest(url: url) + request.timeoutInterval = timeout + + let data: Data + let response: URLResponse + do { + (data, response) = try await transport.fetch(request: request) + } catch let error as ImageLoadError { + throw error + } catch { + throw ImageLoadError.downloadFailed(error.localizedDescription) + } + + try validate(response: response, data: data, decoder: decoder) + + let image = try decoder.decode(data, maxPixelCount: maxPixelCount) + cache.set(urlString, image: image) + return image + } + + /// Rejects responses with error status codes or oversized bodies. + private static func validate( + response: URLResponse, + data: Data, + decoder: PureSwiftImageDecoder + ) throws { + if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + throw ImageLoadError.downloadFailed( + "HTTP \(http.statusCode) for \(http.url?.absoluteString ?? "URL")" + ) + } + + let limit = decoder.maxInputBytes + guard limit >= 0 else { + throw ImageLoadError.inputTooLarge(byteCount: 0, limit: limit) + } + guard data.count <= limit else { + throw ImageLoadError.inputTooLarge(byteCount: data.count, limit: limit) + } + } +} diff --git a/Tests/TUIkitImageTests/RecordingURLTransport.swift b/Tests/TUIkitImageTests/RecordingURLTransport.swift new file mode 100644 index 000000000..b21cae5bb --- /dev/null +++ b/Tests/TUIkitImageTests/RecordingURLTransport.swift @@ -0,0 +1,106 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// RecordingURLTransport.swift +// +// Created by LAYERED.work +// License: MIT + +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +@testable import TUIkitImage + +/// Scripted transport recording every call and returning enqueued responses. +/// +/// Tests drive the transport by calling ``enqueue(data:response:)`` before a +/// coordinator load; the calls are surfaced through ``calls``. ``pause(atCallIndex:)`` +/// suspends a specific call so tests can observe intermediate state +/// (dedup, cancellation) before resolving it. +actor RecordingURLTransport: URLImageDataTransport { + + /// A recorded transport invocation. + struct Call: Sendable { + let request: URLRequest + } + + /// Every call the transport received, in order. + private(set) var calls: [Call] = [] + + /// FIFO of scripted responses. + private var responses: [(Data, URLResponse)] = [] + + /// Call indices whose fetch waits on a continuation. + private var pausedIndices: Set = [] + + /// Continuations for currently paused fetches. + private var pausedContinuations: [CheckedContinuation] = [] + + /// Enqueues a scripted response returned by the next fetch. + func enqueue(data: Data, response: URLResponse) { + responses.append((data, response)) + } + + /// Suspends the fetch at the given zero-based call index until + /// ``resumePaused()`` runs. + func pause(atCallIndex index: Int) { + pausedIndices.insert(index) + } + + /// Resumes every currently paused fetch. + func resumePaused() { + let continuations = pausedContinuations + pausedContinuations.removeAll() + for continuation in continuations { + continuation.resume() + } + } + + /// Number of paused fetches cancelled since the last observation. + private var cancelledFetchCount = 0 + + // MARK: - URLImageDataTransport + + func fetch(request: URLRequest) async throws -> (Data, URLResponse) { + let callIndex = calls.count + calls.append(Call(request: request)) + + if pausedIndices.contains(callIndex) { + try await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + pausedContinuations.append(continuation) + } + try Task.checkCancellation() + } onCancel: { + Task { await self.recordCancelledFetch() } + } + } + + try Task.checkCancellation() + + guard !responses.isEmpty else { + throw URLError(.badServerResponse) + } + return responses.removeFirst() + } + + /// Waits up to `timeout` seconds for a paused fetch to observe cancellation. + /// + /// Poll rather than block so a missing cancellation surfaces as `false` + /// instead of a hung test. + func awaitCancelledFetch(timeout: TimeInterval) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if cancelledFetchCount > 0 { + cancelledFetchCount -= 1 + return true + } + try? await Task.sleep(nanoseconds: 5_000_000) + } + return false + } + + /// Records that a paused fetch was cancelled while waiting. + private func recordCancelledFetch() { + cancelledFetchCount += 1 + } +} diff --git a/Tests/TUIkitImageTests/URLImageCoordinatorTests.swift b/Tests/TUIkitImageTests/URLImageCoordinatorTests.swift new file mode 100644 index 000000000..438bc2318 --- /dev/null +++ b/Tests/TUIkitImageTests/URLImageCoordinatorTests.swift @@ -0,0 +1,230 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// URLImageCoordinatorTests.swift +// +// Created by LAYERED.work +// License: MIT + +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import TUIkitImage + +@Suite("URL Image Coordinator", .timeLimit(.minutes(1))) +struct URLImageCoordinatorTests { + + /// One-pixel PNG shared by all fixtures. + private static let onePixelPNGBytes = Data( + base64Encoded: """ + iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg== + """.replacingOccurrences(of: "\n", with: "") + )! + + /// Creates a fresh coordinator with a scripted transport. + private func makeCoordinator( + transport: RecordingURLTransport = RecordingURLTransport() + ) -> (URLImageCoordinator, RecordingURLTransport) { + (URLImageCoordinator(transport: transport), transport) + } + + // MARK: - Cache Hit/Miss + + @Test("A cache hit skips the transport") + func cacheHitSkipsTransport() async throws { + let cache = URLImageCache() + let cachedImage = RGBAImage( + width: 1, + height: 1, + pixels: [RGBA(r: 0, g: 0, b: 0)] + ) + cache.set("https://cached.test/image.png", image: cachedImage) + + let (coordinator, transport) = makeCoordinator() + let image = try await coordinator.load( + "https://cached.test/image.png", + cache: cache, + timeout: 5, + maxPixelCount: nil, + decoder: PureSwiftImageDecoder(limits: .default) + ) + + #expect(image.width == 1) + let count = await transport.calls.count + #expect(count == 0) + } + + @Test("A cache miss fetches, decodes, and stores") + func cacheMissFetchesAndStores() async throws { + let cache = URLImageCache() + let transport = RecordingURLTransport() + await transport.enqueue( + data: Self.onePixelPNGBytes, + response: HTTPURLResponse( + url: URL(string: "https://miss.test/image.png")!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "image/png"] + )! + ) + let (coordinator, _) = makeCoordinator(transport: transport) + + _ = try await coordinator.load( + "https://miss.test/image.png", + cache: cache, + timeout: 5, + maxPixelCount: nil, + decoder: PureSwiftImageDecoder(limits: .default) + ) + + #expect(cache.get("https://miss.test/image.png") != nil) + #expect(await transport.calls.count == 1) + } + + // MARK: - Deduplication + + @Test("Identical in-flight URLs share a single transport call") + func deduplicatesInFlightRequests() async throws { + let cache = URLImageCache() + let transport = RecordingURLTransport() + await transport.pause(atCallIndex: 0) + await transport.enqueue( + data: Self.onePixelPNGBytes, + response: HTTPURLResponse( + url: URL(string: "https://dedup.test/image.png")!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "image/png"] + )! + ) + let (coordinator, _) = makeCoordinator(transport: transport) + + async let first = coordinator.load( + "https://dedup.test/image.png", + cache: cache, + timeout: 5, + maxPixelCount: nil, + decoder: PureSwiftImageDecoder(limits: .default) + ) + async let second = coordinator.load( + "https://dedup.test/image.png", + cache: cache, + timeout: 5, + maxPixelCount: nil, + decoder: PureSwiftImageDecoder(limits: .default) + ) + + // Wait for both tasks to reach the transport before releasing it. + try await Task.sleep(nanoseconds: 50_000_000) + await transport.resumePaused() + + _ = try await first + _ = try await second + + #expect(await transport.calls.count == 1) + } + + // MARK: - Validation + + @Test("A non-2xx response fails with downloadFailed") + func nonSuccessStatusFails() async { + let transport = RecordingURLTransport() + await transport.enqueue( + data: Data(), + response: HTTPURLResponse( + url: URL(string: "https://fail.test/image.png")!, + statusCode: 404, + httpVersion: nil, + headerFields: nil + )! + ) + let (coordinator, _) = makeCoordinator(transport: transport) + + do { + _ = try await coordinator.load( + "https://fail.test/image.png", + cache: URLImageCache(), + timeout: 5, + maxPixelCount: nil, + decoder: PureSwiftImageDecoder(limits: .default) + ) + Issue.record("Expected 404 to reject") + } catch let error as ImageLoadError { + guard case .downloadFailed(let reason) = error else { + Issue.record("Unexpected error: \(error)") + return + } + #expect(reason.contains("404")) + } catch { + Issue.record("Unexpected error type: \(error)") + } + } + + @Test("A response above the loader byte limit is rejected") + func rejectsOversizedResponse() async { + let transport = RecordingURLTransport() + await transport.enqueue( + data: Data(repeating: 0, count: 32), + response: HTTPURLResponse( + url: URL(string: "https://big.test/image.png")!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + ) + let (coordinator, _) = makeCoordinator(transport: transport) + let decoder = PureSwiftImageDecoder(limits: ImageDecodingLimits(maxInputBytes: 4)) + + do { + _ = try await coordinator.load( + "https://big.test/image.png", + cache: URLImageCache(), + timeout: 5, + maxPixelCount: nil, + decoder: decoder + ) + Issue.record("Expected byte limit to reject the download") + } catch let error as ImageLoadError { + guard case .inputTooLarge(let byteCount, limit: 4) = error else { + Issue.record("Unexpected error: \(error)") + return + } + #expect(byteCount == 32) + } catch { + Issue.record("Unexpected error type: \(error)") + } + } + + // MARK: - Cancellation + + @Test("A cancelled caller forwards cancellation to the in-flight task") + func cancellationForwardsToInFlightTask() async throws { + let transport = RecordingURLTransport() + await transport.pause(atCallIndex: 0) + let (coordinator, _) = makeCoordinator(transport: transport) + + let task = Task { + try await coordinator.load( + "https://cancel.test/image.png", + cache: URLImageCache(), + timeout: 5, + maxPixelCount: nil, + decoder: PureSwiftImageDecoder(limits: .default) + ) + } + try await Task.sleep(nanoseconds: 20_000_000) + + // Cancelling the outer caller must eventually cancel the shared + // in-flight task; the paused transport observes that as a + // cancelled resume. + task.cancel() + + let observed = await transport.awaitCancelledFetch(timeout: 1.0) + #expect(observed == true) + + // Release the paused fetch so pending clean-up completes and the + // test never leaves a live task behind. + await transport.resumePaused() + _ = try? await task.value + } +} From 41db9db8d83549e7976a3f1cb41886fc6a407cf8 Mon Sep 17 00:00:00 2001 From: phranck Date: Fri, 24 Jul 2026 16:06:57 +0200 Subject: [PATCH 2/4] Refactor: Route PlatformImageLoader URL loads through the coordinator - ImageLoader.loadImage(from urlString:...) is async now; the default falls back to cache lookup and the platform loader delegates to URLImageCoordinator - Delete the DispatchSemaphore + nonisolated(unsafe) BoundedURLImageDataLoader - _ImageCore's mounted task awaits the async URL loader - Update the RecordingRuntimeImageLoader stub and drop the byte-limit test that exercised the removed helper (coverage moved to the URLImageCoordinator suite) --- Sources/TUIkit/Views/_ImageCore.swift | 2 +- Sources/TUIkitImage/ImageLoader.swift | 194 +++--------------- .../ImageURLLoadingTests.swift | 28 +-- Tests/TUIkitTests/TUIContextTests.swift | 10 +- 4 files changed, 40 insertions(+), 194 deletions(-) diff --git a/Sources/TUIkit/Views/_ImageCore.swift b/Sources/TUIkit/Views/_ImageCore.swift index 21848787f..6d2c19bac 100644 --- a/Sources/TUIkit/Views/_ImageCore.swift +++ b/Sources/TUIkit/Views/_ImageCore.swift @@ -169,7 +169,7 @@ extension _ImageCore { case .file(let path): rawImage = try imageLoader.loadImage(from: path, maxPixelCount: maxPixelCount) case .url(let urlString): - rawImage = try imageLoader.loadImage( + rawImage = try await imageLoader.loadImage( from: urlString, cache: imageCache, timeout: urlTimeout, diff --git a/Sources/TUIkitImage/ImageLoader.swift b/Sources/TUIkitImage/ImageLoader.swift index a20bcab5c..11fd05968 100644 --- a/Sources/TUIkitImage/ImageLoader.swift +++ b/Sources/TUIkitImage/ImageLoader.swift @@ -10,140 +10,10 @@ import Foundation import FoundationNetworking #endif -enum BoundedURLImageDataLoader { - - static func load( - request: URLRequest, - maxByteCount: Int, - configuration: URLSessionConfiguration = .ephemeral - ) throws -> Data { - guard maxByteCount >= 0 else { - throw ImageLoadError.inputTooLarge(byteCount: 0, limit: maxByteCount) - } - - return try SessionDelegate(maxByteCount: maxByteCount).load( - request: request, - configuration: configuration - ) - } -} - -private extension BoundedURLImageDataLoader { - - final class SessionDelegate: NSObject, URLSessionDataDelegate, @unchecked Sendable { - private let maxByteCount: Int - private let completionSemaphore = DispatchSemaphore(value: 0) - private let stateLock = NSLock() - private var bufferedData = Data() - private var result: Result? - - init(maxByteCount: Int) { - self.maxByteCount = maxByteCount - } - - func load(request: URLRequest, configuration: URLSessionConfiguration) throws -> Data { - let session = URLSession( - configuration: configuration, - delegate: self, - delegateQueue: nil - ) - let task = session.dataTask(with: request) - task.resume() - completionSemaphore.wait() - session.invalidateAndCancel() - - stateLock.lock() - let completedResult = result - stateLock.unlock() - - guard let completedResult else { - throw ImageLoadError.downloadFailed("Download finished without a result") - } - return try completedResult.get() - } - - func urlSession( - _ session: URLSession, - dataTask: URLSessionDataTask, - didReceive response: URLResponse, - completionHandler: @escaping @Sendable (URLSession.ResponseDisposition) -> Void - ) { - let expectedByteCount = response.expectedContentLength - if expectedByteCount > Int64(maxByteCount) { - let reportedByteCount = min(expectedByteCount, Int64(Int.max)) - finish( - with: .failure( - ImageLoadError.inputTooLarge( - byteCount: Int(reportedByteCount), - limit: maxByteCount - ) - ) - ) - completionHandler(.cancel) - return - } - - completionHandler(.allow) - } - - func urlSession( - _ session: URLSession, - dataTask: URLSessionDataTask, - didReceive data: Data - ) { - stateLock.lock() - guard result == nil else { - stateLock.unlock() - dataTask.cancel() - return - } - - let remainingByteCount = maxByteCount - bufferedData.count - guard data.count <= remainingByteCount else { - let reportedByteCount = maxByteCount == Int.max ? Int.max : maxByteCount + 1 - result = .failure( - ImageLoadError.inputTooLarge( - byteCount: reportedByteCount, - limit: maxByteCount - ) - ) - stateLock.unlock() - completionSemaphore.signal() - dataTask.cancel() - return - } - - bufferedData.append(data) - stateLock.unlock() - } - - func urlSession( - _ session: URLSession, - task: URLSessionTask, - didCompleteWithError error: Error? - ) { - if let error { - finish(with: .failure(error)) - } else { - stateLock.lock() - let data = bufferedData - stateLock.unlock() - finish(with: .success(data)) - } - } - - private func finish(with completedResult: Result) { - stateLock.lock() - guard result == nil else { - stateLock.unlock() - return - } - result = completedResult - stateLock.unlock() - completionSemaphore.signal() - } - } -} +// The former DispatchSemaphore-based URL data loader was removed in +// favor of the async URLImageCoordinator pipeline: sync semaphore waits +// blocked concurrency-runtime threads on Linux and required +// nonisolated(unsafe) result state. See `URLImageCoordinator.swift`. // MARK: - ImageLoader Protocol @@ -170,12 +40,16 @@ public protocol ImageLoader: Sendable { func loadImage(from path: String, maxPixelCount: Int?) throws -> RGBAImage /// Loads an image from a URL using the supplied runtime cache. + /// + /// The default implementation returns the cached image if present and + /// otherwise fails; concrete loaders (like `PlatformImageLoader`) fetch + /// through the async pipeline. func loadImage( from urlString: String, cache: URLImageCache, timeout: TimeInterval, maxPixelCount: Int? - ) throws -> RGBAImage + ) async throws -> RGBAImage } // MARK: - ImageLoader Defaults @@ -192,7 +66,7 @@ public extension ImageLoader { cache: URLImageCache, timeout: TimeInterval, maxPixelCount: Int? - ) throws -> RGBAImage { + ) async throws -> RGBAImage { if let image = cache.get(urlString) { try validatePixelCount(image, maxPixelCount: maxPixelCount) return image @@ -273,9 +147,20 @@ public enum ImageLoadError: Error, LocalizedError, CustomStringConvertible { /// Cross-platform image loader backed by pure Swift PNG and JPEG decoders. public struct PlatformImageLoader: ImageLoader { private let decoder: PureSwiftImageDecoder + private let coordinator: URLImageCoordinator public init(limits: ImageDecodingLimits = .default) { - decoder = PureSwiftImageDecoder(limits: limits) + self.decoder = PureSwiftImageDecoder(limits: limits) + self.coordinator = URLImageCoordinator() + } + + /// Creates a loader with an explicit URL coordinator. + /// + /// Tests inject a coordinator configured with a scripted transport so + /// no live networking runs; production uses the default. + init(limits: ImageDecodingLimits = .default, coordinator: URLImageCoordinator) { + self.decoder = PureSwiftImageDecoder(limits: limits) + self.coordinator = coordinator } public func loadImage(from path: String) throws -> RGBAImage { @@ -410,33 +295,14 @@ extension PlatformImageLoader { cache: URLImageCache, timeout: TimeInterval = 30, maxPixelCount: Int? = nil - ) throws -> RGBAImage { - if let cached = cache.get(urlString) { - try decoder.validateCachedImage(cached, maxPixelCount: maxPixelCount) - return cached - } - - guard let url = URL(string: urlString) else { - throw ImageLoadError.downloadFailed("Invalid URL: \(urlString)") - } - - let data: Data - do { - var request = URLRequest(url: url) - request.timeoutInterval = timeout - data = try BoundedURLImageDataLoader.load( - request: request, - maxByteCount: decoder.maxInputBytes - ) - } catch let error as ImageLoadError { - throw error - } catch { - throw ImageLoadError.downloadFailed(error.localizedDescription) - } - - let image = try loadImage(from: data, maxPixelCount: maxPixelCount) - cache.set(urlString, image: image) - return image + ) async throws -> RGBAImage { + try await coordinator.load( + urlString, + cache: cache, + timeout: timeout, + maxPixelCount: maxPixelCount, + decoder: decoder + ) } } diff --git a/Tests/TUIkitImageTests/ImageURLLoadingTests.swift b/Tests/TUIkitImageTests/ImageURLLoadingTests.swift index 0752bbf94..7d20d0a47 100644 --- a/Tests/TUIkitImageTests/ImageURLLoadingTests.swift +++ b/Tests/TUIkitImageTests/ImageURLLoadingTests.swift @@ -15,7 +15,7 @@ import Testing struct ImageURLLoadingTests { @Test("URL cache hits enforce the current loader pixel limit") - func cachedImagePixelLimit() { + func cachedImagePixelLimit() async { let urlString = "https://cache.test/issue-16-limit.png" let cachedImage = RGBAImage( width: 2, @@ -29,7 +29,7 @@ struct ImageURLLoadingTests { ) do { - _ = try loader.loadImage(from: urlString, cache: cache) + _ = try await loader.loadImage(from: urlString, cache: cache) Issue.record("Expected the current loader limit to reject the cached image") } catch let error as ImageLoadError { guard case .imageTooLarge(pixelCount: 4, limit: 3) = error else { @@ -40,28 +40,4 @@ struct ImageURLLoadingTests { Issue.record("Unexpected error type: \(error)") } } - - @Test("URL loading stops at the encoded input byte limit") - func urlInputByteLimit() { - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [OversizedImageURLProtocol.self] - let request = URLRequest(url: URL(string: "https://stream.test/oversized-image")!) - - do { - _ = try BoundedURLImageDataLoader.load( - request: request, - maxByteCount: 4, - configuration: configuration - ) - Issue.record("Expected the URL input byte limit to stop the download") - } catch let error as ImageLoadError { - guard case .inputTooLarge(let byteCount, limit: 4) = error else { - Issue.record("Unexpected error: \(error)") - return - } - #expect(byteCount == 5) - } catch { - Issue.record("Unexpected error type: \(error)") - } - } } diff --git a/Tests/TUIkitTests/TUIContextTests.swift b/Tests/TUIkitTests/TUIContextTests.swift index 3b40a7c7d..2f6da01f2 100644 --- a/Tests/TUIkitTests/TUIContextTests.swift +++ b/Tests/TUIkitTests/TUIContextTests.swift @@ -419,12 +419,16 @@ private final class RecordingRuntimeImageLoader: ImageLoader, @unchecked Sendabl cache: URLImageCache, timeout: TimeInterval, maxPixelCount: Int? - ) throws -> RGBAImage { + ) async throws -> RGBAImage { cache.set(urlString, image: image) + recordRequest(urlString) + requestRecorded.signal() + return image + } + + private func recordRequest(_ urlString: String) { lock.lock() urls.append(urlString) lock.unlock() - requestRecorded.signal() - return image } } From e8069a05774f9f85633b17cdc9bcfb91a90481c1 Mon Sep 17 00:00:00 2001 From: phranck Date: Fri, 24 Jul 2026 16:09:35 +0200 Subject: [PATCH 3/4] Test: Cover the URL image pipeline end to end through _ImageCore - Add an actor-based SlowURLImageLoader that counts invocations and a URLImageApp fixture; asserts that reading a URL source triggers exactly one loader call, no state mutates during traversal, and no runtime diagnostics fire --- .../TUIkitTests/ImageAsyncPipelineTests.swift | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 Tests/TUIkitTests/ImageAsyncPipelineTests.swift diff --git a/Tests/TUIkitTests/ImageAsyncPipelineTests.swift b/Tests/TUIkitTests/ImageAsyncPipelineTests.swift new file mode 100644 index 000000000..ec346e673 --- /dev/null +++ b/Tests/TUIkitTests/ImageAsyncPipelineTests.swift @@ -0,0 +1,90 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// ImageAsyncPipelineTests.swift +// +// Created by LAYERED.work +// License: MIT + +import Foundation +import Testing + +@testable import TUIkit + +/// End-to-end coverage for the async URL image pipeline inside +/// `_ImageCore`: the mounted lifecycle task awaits the async loader, +/// commits phase and source together, and never mutates state from +/// within the traversal window. +@MainActor +@Suite("Image Async Pipeline", .serialized) +struct ImageAsyncPipelineTests { + + @Test( + "URL sources commit their phase through the async loader", + .timeLimit(.minutes(1)) + ) + func urlSourceCommitsThroughAsyncLoader() async throws { + let loader = SlowURLImageLoader(pixel: RGBA(r: 128, g: 64, b: 32)) + let tuiContext = TUIContext(imageLoader: loader) + let harness = FrameHarness(app: URLImageApp(), tuiContext: tuiContext) + + // First frame renders the loading placeholder; the async task is + // recorded for the frame commit and completes shortly after. + harness.renderFrame() + + #expect(tuiContext.runtimeDiagnostics.messages.isEmpty) + + // Give the injected loader up to a second to complete its call. + let deadline = Date().addingTimeInterval(1.0) + while Date() < deadline, await loader.calls == 0 { + try await Task.sleep(nanoseconds: 10_000_000) + } + #expect(await loader.calls == 1) + + // A follow-up frame renders the committed image without traversal + // diagnostics: no state was mutated by reading the same source. + harness.renderFrame() + #expect(tuiContext.runtimeDiagnostics.messages.isEmpty) + } +} + +// MARK: - Fixtures + +/// An `ImageLoader` whose URL path counts invocations and simulates a +/// short round-trip. +private actor SlowURLImageLoader: ImageLoader { + private(set) var calls = 0 + private let image: RGBAImage + + init(pixel: RGBA) { + self.image = RGBAImage(width: 1, height: 1, pixels: [pixel]) + } + + nonisolated func loadImage(from path: String) throws -> RGBAImage { + fatalError("URL fixture never touches file sources") + } + + nonisolated func loadImage(from data: Data) throws -> RGBAImage { + fatalError("URL fixture never touches raw data") + } + + func loadImage( + from urlString: String, + cache: URLImageCache, + timeout: TimeInterval, + maxPixelCount: Int? + ) async throws -> RGBAImage { + try await Task.sleep(nanoseconds: 5_000_000) + calls += 1 + cache.set(urlString, image: image) + return image + } +} + +private struct URLImageApp: App { + init() {} + + var body: some Scene { + WindowGroup { + Image(.url("https://async.test/image.png")) + } + } +} From 0508052ec4ce5215879f4ec1204ced18d3832d9f Mon Sep 17 00:00:00 2001 From: phranck Date: Fri, 24 Jul 2026 16:20:27 +0200 Subject: [PATCH 4/4] Chore: Regenerate the API compatibility manifest for the async image pipeline - Drop overrides for the removed BoundedURLImageDataLoader helper - Add overrides for the new async loadImage(from urlString:...) signature on ImageLoader and PlatformImageLoader - Regenerate the manifest with TUIkitAPICheck against fresh 6.0.3 macOS and Linux snapshots --- .../Configuration/compatibility-manifest.json | 6 ++-- .../Configuration/review-policy.json | 30 +++++++++---------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/Tools/APICompatibility/Configuration/compatibility-manifest.json b/Tools/APICompatibility/Configuration/compatibility-manifest.json index fd61b0196..4b2b80f93 100644 --- a/Tools/APICompatibility/Configuration/compatibility-manifest.json +++ b/Tools/APICompatibility/Configuration/compatibility-manifest.json @@ -339404,7 +339404,7 @@ { "classification" : "implementationLeak", "ownerIssue" : "#35", - "symbolID" : "s:11TUIkitImage08PlatformB6LoaderV04loadB04from5cache7timeout13maxPixelCountAA9RGBAImageVSS_AA13URLImageCacheCSdSiSgtKF" + "symbolID" : "s:11TUIkitImage08PlatformB6LoaderV04loadB04from5cache7timeout13maxPixelCountAA9RGBAImageVSS_AA13URLImageCacheCSdSiSgtYaKF" }, { "classification" : "implementationLeak", @@ -339514,7 +339514,7 @@ { "classification" : "implementationLeak", "ownerIssue" : "#35", - "symbolID" : "s:11TUIkitImage0B6LoaderP04loadB04from5cache7timeout13maxPixelCountAA9RGBAImageVSS_AA13URLImageCacheCSdSiSgtKF" + "symbolID" : "s:11TUIkitImage0B6LoaderP04loadB04from5cache7timeout13maxPixelCountAA9RGBAImageVSS_AA13URLImageCacheCSdSiSgtYaKF" }, { "classification" : "implementationLeak", @@ -339539,7 +339539,7 @@ { "classification" : "implementationLeak", "ownerIssue" : "#35", - "symbolID" : "s:11TUIkitImage0B6LoaderPAAE04loadB04from5cache7timeout13maxPixelCountAA9RGBAImageVSS_AA13URLImageCacheCSdSiSgtKF" + "symbolID" : "s:11TUIkitImage0B6LoaderPAAE04loadB04from5cache7timeout13maxPixelCountAA9RGBAImageVSS_AA13URLImageCacheCSdSiSgtYaKF" }, { "classification" : "tuiSpecific", diff --git a/Tools/APICompatibility/Configuration/review-policy.json b/Tools/APICompatibility/Configuration/review-policy.json index cf92a67da..4a8d1a3d4 100644 --- a/Tools/APICompatibility/Configuration/review-policy.json +++ b/Tools/APICompatibility/Configuration/review-policy.json @@ -19633,11 +19633,6 @@ "ownerIssue": "#35", "symbolID": "s:11TUIkitImage08PlatformB6LoaderV04loadB04from13maxPixelCountAA9RGBAImageVSS_SiSgtKF" }, - { - "action": "implementationLeak", - "ownerIssue": "#35", - "symbolID": "s:11TUIkitImage08PlatformB6LoaderV04loadB04from5cache7timeout13maxPixelCountAA9RGBAImageVSS_AA13URLImageCacheCSdSiSgtKF" - }, { "action": "implementationLeak", "ownerIssue": "#35", @@ -19743,11 +19738,6 @@ "ownerIssue": "#35", "symbolID": "s:11TUIkitImage0B6LoaderP04loadB04from13maxPixelCountAA9RGBAImageVSS_SiSgtKF" }, - { - "action": "implementationLeak", - "ownerIssue": "#35", - "symbolID": "s:11TUIkitImage0B6LoaderP04loadB04from5cache7timeout13maxPixelCountAA9RGBAImageVSS_AA13URLImageCacheCSdSiSgtKF" - }, { "action": "implementationLeak", "ownerIssue": "#35", @@ -19768,11 +19758,6 @@ "ownerIssue": "#35", "symbolID": "s:11TUIkitImage0B6LoaderPAAE04loadB04from13maxPixelCountAA9RGBAImageVSS_SiSgtKF" }, - { - "action": "implementationLeak", - "ownerIssue": "#35", - "symbolID": "s:11TUIkitImage0B6LoaderPAAE04loadB04from5cache7timeout13maxPixelCountAA9RGBAImageVSS_AA13URLImageCacheCSdSiSgtKF" - }, { "action": "tuiSpecific", "ownerIssue": "#35", @@ -26207,6 +26192,21 @@ "action": "implementationLeak", "ownerIssue": "#35", "symbolID": "s:6TUIkit5ScenePAAE7paletteyQr0A7Styling7Palette_pF" + }, + { + "action": "implementationLeak", + "ownerIssue": "#35", + "symbolID": "s:11TUIkitImage08PlatformB6LoaderV04loadB04from5cache7timeout13maxPixelCountAA9RGBAImageVSS_AA13URLImageCacheCSdSiSgtYaKF" + }, + { + "action": "implementationLeak", + "ownerIssue": "#35", + "symbolID": "s:11TUIkitImage0B6LoaderP04loadB04from5cache7timeout13maxPixelCountAA9RGBAImageVSS_AA13URLImageCacheCSdSiSgtYaKF" + }, + { + "action": "implementationLeak", + "ownerIssue": "#35", + "symbolID": "s:11TUIkitImage0B6LoaderPAAE04loadB04from5cache7timeout13maxPixelCountAA9RGBAImageVSS_AA13URLImageCacheCSdSiSgtYaKF" } ] }