diff --git a/CHANGELOG.md b/CHANGELOG.md index 00536a9..5239499 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed +- **Background Decoding Guarantee**: `downsample` and `fetchRemoteImage` are now marked `@concurrent`, ensuring image decoding always runs on the cooperative thread pool even when the `NonisolatedNonsendingByDefault` language mode is active. +- **Coalescing Race**: Cleanup and cancellation bookkeeping now verify task identity before touching the active-task table, preventing a stale cancellation handler from cancelling a newer, unrelated request for the same URL. +- **Cancellation Error Normalization**: Cancelled requests now consistently throw `CancellationError`, even when the underlying `URLSession` surfaces `URLError(.cancelled)`. +- **Idle Instrumentation Wakeups**: `StandardImageStreamerInstrumentation` no longer runs a permanent 10Hz polling task; broadcasts are scheduled on demand (still throttled to ~10Hz under load). +- **Environment Defaults**: The `\.imageStreamer` and `\.instrumentation` environment defaults now share stable instances wired to each other, instead of constructing disconnected instances on each fallback access. + +### Changed +- **BREAKING — Cache Type**: `ImageStreamer.init` now takes an `ImageCache` (a thread-safe wrapper the library owns) instead of `NSCache`. The retroactive `@unchecked Sendable` conformance on `NSCache`, which applied process-wide, has been removed. +- **Test Determinism**: Coalescing tests now use a gated mock fetcher instead of fixed delays, removing timing dependence. + ## [1.0.1] - 2026-03-01 ### Added diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5287949 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,48 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +ImageStreamer is a single-product Swift Package: a concurrency-first image loading/caching library for Apple platforms (iOS 17+, macOS 14+, tvOS 17+, watchOS 10+, visionOS 1+). `swift-tools-version: 6.2`, strict concurrency. No third-party dependencies. + +## Commands + +Requires an Xcode 15+ / Swift 6.2 toolchain. + +```bash +swift build # build the library +swift test # run the full test suite +swift test --filter ImageStreamerCoreTests # run one @Suite +swift test --filter "Loads and returns a valid image" # run one @Test by name +``` + +Tests use **swift-testing** (`@Suite`/`@Test`/`#expect`), not XCTest. `--filter` matches against suite and test display names (regex). + +The example app (`Example/ImageStreamerApp.swiftpm`) is a Swift Playground app, **not** part of `swift build`/`swift test`. Open it in Xcode to run it; it depends on the library via a local `.package(path: "../..")`. + +## Architecture + +**`ImageStreamer` (actor)** — the coordinator. Its public `image(for:pointSize:)` is `nonisolated` on purpose: the cache lookup is a synchronous "fast path" that never touches actor-isolated state, so cache hits don't serialize through the actor. Only on a miss does it `await` into `fetchAndCoalesce`, which re-checks the cache (another caller may have populated it while suspended) before coalescing or starting a fetch. + +**Task coalescing + reference counting** is the heart of the actor and the easiest place to introduce bugs. `activeTasks: [ImageCacheKey: CoalescedTask]` maps a key to one in-flight `Task` plus a `waiterCount`. Concurrent callers for the same key join the existing task instead of starting a new fetch; each increments `waiterCount`. Cancellation is per-caller via `withTaskCancellationHandler` → `handleCancellation`, which decrements `waiterCount` and only cancels the underlying network task when it reaches zero. Two invariants run through this code, guarded by `activeTasks[key]?.task == task` checks everywhere: +- A newer request for the same key may have **replaced** the entry while a task was suspended — never clean up or mutate an entry that isn't still "yours." +- `handleCancellation` runs from an unstructured `Task` at an arbitrary later time, so it must re-validate the entry identity before acting. +Cancelled callers re-throw `CancellationError` (via `Task.checkCancellation()`) rather than leaking the underlying `URLError(.cancelled)`. + +**Dual decode path** in `fetchRemoteImage`/`downsample`: with a `pointSize`, ImageIO (`CGImageSourceCreateThumbnailAtIndex`) decodes + downsamples on a background thread, scaled by a per-platform device scale factor. Without one, the full image is decoded and (on UIKit) `byPreparingForDisplay()` is called. This is why format support differs between downsampled and full-size — thumbnail generation only yields the first frame of animated formats and doesn't rasterize vectors. + +**`ImageCacheKey`** is `URL` + optional `pointSize`. The pointSize is part of the key so a thumbnail and the full-size image of the same URL are cached as **separate** entries. It's an `NSObject`/`NSCopying` (immutable, `final`, `copy` returns `self`) so it can key `NSCache`. + +**`ImageCache`** wraps `NSCache` and is `@unchecked Sendable` (NSCache is documented thread-safe; wrapping avoids a process-wide retroactive `Sendable` conformance). Eviction cost is computed as pixel-area × 4 bytes. + +**Instrumentation** is fully decoupled: `ImageStreamerInstrumentation` is its own `Actor` protocol, optional, injected at init. The streamer fires `notify*` calls in detached `Task`s so instrumentation never blocks the loading path. `StandardImageStreamerInstrumentation` broadcasts via per-observer `AsyncStream`s with a **100ms (10Hz) throttle** (`scheduleFlush`) to avoid flooding the UI; updates within a window coalesce into one flush. `reset()` flushes immediately. + +**SwiftUI integration** lives at the bottom of `ImageStreamer.swift`: `EnvironmentValues.imageStreamer` and `.instrumentation` via `@Entry`. Because `@Entry` re-evaluates its default on every fallback access, the defaults reference **shared** instances in `ImageStreamerDefaults` (not freshly constructed ones) — this keeps the default streamer wired to the default instrumentation so stats observers see its activity. Don't change these defaults to inline constructors. + +**`PlatformImage`** is a typealias bridging `UIImage`/`NSImage`; platform differences (display scale, `byPreparingForDisplay`, CGImage→image construction) are handled with `#if canImport(UIKit)/AppKit` and `os(...)` checks throughout. + +## Conventions + +- Pure Swift Concurrency — `async`/`await` and actors only. No completion handlers or explicit `DispatchQueue`. +- Everything crossing isolation boundaries is `Sendable`; the public surface sits behind protocols (`ImageStreamerProtocol`, `ImageFetching`, `ImageStreamerInstrumentation`) to keep it injectable/mockable. +- Tests mock the network via the `ImageFetching` protocol. Helpers in `ImageStreamerTests.swift`: `makeStreamer` (single or URL-mapped responses), `makeGatedStreamer` (fetches block on a `RequestGate` so tests can *guarantee* overlapping requests instead of relying on sleeps). Prefer gated mocks over fixed delays when asserting coalescing/cancellation behavior. Mocks live in `Tests/ImageStreamerTests/Mocks/`. +- When public APIs change, update the docs in `Documentation/` (`USAGE.md`, `ADVANCED.md`, `SHOWCASE.md`) and `README.md`. diff --git a/Documentation/ADVANCED.md b/Documentation/ADVANCED.md index 84e4b32..cd583d0 100644 --- a/Documentation/ADVANCED.md +++ b/Documentation/ADVANCED.md @@ -27,7 +27,7 @@ In infinite scroll scenarios, views frequently move off-screen before their imag ## Caching Strategy -`ImageStreamer` uses an internal `NSCache` instance. This offers key advantages: +`ImageStreamer` uses an `ImageCache`, a lightweight thread-safe wrapper around `NSCache`. This offers key advantages: - Thread-safe access. - Automatic, system-level pruning when memory is low (preventing OOM crashes). - Cost-based eviction (calculated based on the image's pixel count or size in memory). @@ -66,7 +66,7 @@ The entire `ImageStreamer` sits behind protocols (`ImageStreamerProtocol`, `Imag ```swift public init( session: ImageFetching = URLSession.shared, - cache: NSCache = NSCache(), + cache: ImageCache = ImageCache(), instrumentation: ImageStreamerInstrumentation? = nil ) ``` diff --git a/Example/ImageStreamerApp.swiftpm/Components/ImageGridView.swift b/Example/ImageStreamerApp.swiftpm/Components/ImageGridView.swift index 3e76f22..d510b07 100644 --- a/Example/ImageStreamerApp.swiftpm/Components/ImageGridView.swift +++ b/Example/ImageStreamerApp.swiftpm/Components/ImageGridView.swift @@ -14,8 +14,7 @@ struct ImageGridView: View { @Environment(\.imageStreamer) private var imageStreamer @State private var imageIDs: [Int] = Array(1...50) - @State private var isLoadingMore = false - + private let batchSize = 50 private let columns = [ @@ -35,12 +34,6 @@ struct ImageGridView: View { } } } - - if isLoadingMore { - ProgressView() - .frame(maxWidth: .infinity) - .padding() - } } .padding(4) } @@ -54,17 +47,8 @@ struct ImageGridView: View { } private func loadMoreImages() { - guard !isLoadingMore else { return } - - isLoadingMore = true - - Task { - let nextStart = imageIDs.count + 1 - let newIDs = Array(nextStart...(nextStart + batchSize - 1)) - imageIDs.append(contentsOf: newIDs) - - isLoadingMore = false - } + let nextStart = imageIDs.count + 1 + imageIDs.append(contentsOf: nextStart...(nextStart + batchSize - 1)) } /// Prefetches images without blocking - warms the cache. diff --git a/Example/ImageStreamerApp.swiftpm/Components/RemoteImageCell.swift b/Example/ImageStreamerApp.swiftpm/Components/RemoteImageCell.swift index dd17b78..096c181 100644 --- a/Example/ImageStreamerApp.swiftpm/Components/RemoteImageCell.swift +++ b/Example/ImageStreamerApp.swiftpm/Components/RemoteImageCell.swift @@ -76,11 +76,11 @@ struct RemoteImageCell: View { for: url, pointSize: CGSize(width: 150, height: 150) ) - image = loadedImage // Check for cancellation before updating state try Task.checkCancellation() + image = loadedImage loadState = .loaded } catch is CancellationError { // Task was cancelled (view scrolled away) diff --git a/Example/ImageStreamerApp.swiftpm/ContentView.swift b/Example/ImageStreamerApp.swiftpm/ContentView.swift index f05d5ca..bc0178b 100644 --- a/Example/ImageStreamerApp.swiftpm/ContentView.swift +++ b/Example/ImageStreamerApp.swiftpm/ContentView.swift @@ -44,7 +44,6 @@ struct ContentView: View { ContentView() .environment(\.imageStreamer, ImageStreamer( session: URLSession.shared, - cache: NSCache(), instrumentation: StandardImageStreamerInstrumentation() )) } diff --git a/Example/ImageStreamerApp.swiftpm/Package.swift b/Example/ImageStreamerApp.swiftpm/Package.swift index 0cf1a52..e2b208d 100644 --- a/Example/ImageStreamerApp.swiftpm/Package.swift +++ b/Example/ImageStreamerApp.swiftpm/Package.swift @@ -23,8 +23,8 @@ let package = Package( teamIdentifier: "JM8BMXQ5NG", displayVersion: "1.0", bundleVersion: "1", - appIcon: .placeholder(icon: .note), - accentColor: .presetColor(.cyan), + appIcon: .placeholder(icon: .images), + accentColor: .presetColor(.indigo), supportedDeviceFamilies: [ .pad, .phone @@ -50,5 +50,5 @@ let package = Package( path: "." ) ], - swiftLanguageModes: [.version("6")] -) + swiftLanguageVersions: [.version("6")] +) \ No newline at end of file diff --git a/Example/ImageStreamerApp.swiftpm/ShowcaseApp.swift b/Example/ImageStreamerApp.swiftpm/ShowcaseApp.swift index 55b7daf..d02bf0e 100644 --- a/Example/ImageStreamerApp.swiftpm/ShowcaseApp.swift +++ b/Example/ImageStreamerApp.swiftpm/ShowcaseApp.swift @@ -4,18 +4,22 @@ import ImageStreamer @main struct ShowcaseApp: App { // Create instrumentation for stats tracking - private let instrumentation = StandardImageStreamerInstrumentation() - - // Create a shared, long-lived instance for the entire app - // This enables effective caching and task coalescing - private var imageStreamer: ImageStreamer { - ImageStreamer( + private let instrumentation: StandardImageStreamerInstrumentation + + // A single, long-lived instance shared by the entire app. + // Stored (not computed) so the cache and task coalescing survive body re-evaluations. + private let imageStreamer: ImageStreamer + + init() { + let instrumentation = StandardImageStreamerInstrumentation() + self.instrumentation = instrumentation + self.imageStreamer = ImageStreamer( session: URLSession.shared, - cache: NSCache(), instrumentation: instrumentation ) } - + + var body: some Scene { WindowGroup { ContentView() diff --git a/GEMINI.md b/GEMINI.md deleted file mode 100644 index bcd8c1f..0000000 --- a/GEMINI.md +++ /dev/null @@ -1,52 +0,0 @@ -# ImageStreamer Project Context - -## Project Overview -**ImageStreamer** is a lightweight, high-performance image loading and caching library for Apple platforms (iOS 17+, macOS 14+, tvOS 17+, watchOS 10+, visionOS 1+). It is built entirely with modern Swift Concurrency (`async`/`await`, `actors`) and is optimized for rapidly populating demanding UIs like grids and infinite lists. - -### Key Features -- **Task Coalescing**: Automatically merges duplicate concurrent requests for the same URL and size into a single network task. -- **Reference-Counted Cancellation**: Cancels underlying network tasks only when all interested callers have cancelled their requests. -- **Background Decoding & Downsampling**: Uses `ImageIO` to decode and resize images on background threads before they reach the main thread, reducing memory footprint and preventing UI stutters. -- **Smart Caching**: Leverages `NSCache` for thread-safe, auto-pruning in-memory storage. -- **Instrumentation**: Provides a real-time stream of performance statistics (cache hits, network requests, coalesced savings, etc.). - -### Architecture -- `ImageStreamer`: A central `actor` that coordinates fetching, coalescing, and caching. -- `ImageCacheKey`: Identifies cached assets by URL and `pointSize` (to cache thumbnails separately from full-size images). -- `ImageStreamerInstrumentation`: Protocol and default implementation for tracking and broadcasting stats via `AsyncStream`. -- `ImageFetching`: Protocol abstraction over `URLSession` to allow easy mocking in tests. - -## Building and Running - -### Prerequisites -- Xcode 15.0+ or Swift 6.0+ toolchain. -- Target platforms: iOS 17.0+, macOS 14.0+, tvOS 17.0+, watchOS 10.0+, visionOS 1.0+. - -### Key Commands -- **Build**: `swift build` -- **Test**: `swift test` -- **Lint/Format**: (Inferred) The project follows standard Swift conventions. Use `swift format` if configured. - -### Example Application -The repository includes a Showcase App located at `Example/ImageStreamerApp.swiftpm`. This is a Swift Playground app that demonstrates: -- Grid-based image loading. -- Infinite scrolling performance. -- Real-time instrumentation dashboard. - -## Development Conventions - -### Coding Style -- **Concurrency**: Exclusively uses `async`/`await` and `actors`. Avoid legacy completion handlers or explicit `DispatchQueue` calls. -- **Strict Typing**: Leverage Swift 6.0's strict concurrency checking. All public protocols and classes are `Sendable` where appropriate. -- **Platform Abstraction**: Uses `PlatformImage` typealias to bridge `UIImage` (UIKit) and `NSImage` (AppKit). - -### Testing Practices -- **Framework**: Uses the modern `swift-testing` framework (not XCTest). -- **Mocks**: Dependencies like `ImageFetching` should be mocked using the provided protocols to ensure tests are fast and deterministic. -- **Suite Structure**: Tests are organized into Suites (e.g., `ImageStreamerCoreTests`, `ImageStreamerCachingTests`) using the `@Suite` and `@Test` macros. -- **Async Testing**: Tests are `async` and use `#expect` for assertions. - -### Contribution Guidelines -- Ensure all new features are accompanied by unit tests in `Tests/ImageStreamerTests/`. -- Update documentation in the `Documentation/` folder if public APIs change. -- Verify platform compatibility across the supported Apple ecosystem. diff --git a/README.md b/README.md index d2b9ebc..67aadf5 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ - **Task Coalescing**: Merges duplicate requests for the same URL automatically. - **Task Cancellation**: Controlled cancel requests when they're no longer needed: checks for task cancellation before making a network request, and before intensive CPU and memory usage. - **Background Decoding & Downsampling**: smooth scrolling and lesser memory usage. -- **Smart Caching**: `NSCache`, light, thread-safe, and gracefully prunes itself —It won't crash your app with an Out-of-Memory (OOM) error +- **Smart Caching**: `ImageCache`, light, thread-safe, and gracefully prunes itself — it won't crash your app with an Out-of-Memory (OOM) error. - **Native Async/Await**: Clean, readable, and safe code. - **SwiftUI Ready**: Seamless integration via `EnvironmentValues`. @@ -79,7 +79,7 @@ The entire `ImageStreamer` sits behind protocols (`ImageStreamerProtocol`, `Imag ```swift public init( session: ImageFetching = URLSession.shared, - cache: NSCache = NSCache(), + cache: ImageCache = ImageCache(), instrumentation: ImageStreamerInstrumentation? = nil ) ``` diff --git a/Sources/ImageStreamer/ImageStreamer.swift b/Sources/ImageStreamer/ImageStreamer.swift index 77d0715..e804669 100644 --- a/Sources/ImageStreamer/ImageStreamer.swift +++ b/Sources/ImageStreamer/ImageStreamer.swift @@ -9,7 +9,7 @@ public typealias PlatformImage = UIImage import AppKit public typealias PlatformImage = NSImage #else - #error("Unsupported platform") +#error("Unsupported platform") #endif /// Protocol to abstract the actual network fetching. @@ -64,7 +64,7 @@ public actor ImageStreamer: ImageStreamerProtocol, Instrumentable { // MARK: - Dependencies private nonisolated let session: ImageFetching - private nonisolated let cache: NSCache + private nonisolated let cache: ImageCache private var activeTasks: [ImageCacheKey: CoalescedTask] = [:] @@ -78,7 +78,7 @@ public actor ImageStreamer: ImageStreamerProtocol, Instrumentable { /// - instrumentation: Optional instrumentation for collecting statistics. public init( session: ImageFetching = URLSession.shared, - cache: NSCache = NSCache(), + cache: ImageCache = ImageCache(), instrumentation: ImageStreamerInstrumentation? = nil ) { self.session = session @@ -94,7 +94,7 @@ public actor ImageStreamer: ImageStreamerProtocol, Instrumentable { // Fast path: Check cache without entering actor serialization if let cachedImage = self.cache.object(forKey: key) { if let inst = instrumentation { - Task.detached { await inst.notifyCacheHit() } + Task { await inst.notifyCacheHit() } } return cachedImage } @@ -110,7 +110,7 @@ public actor ImageStreamer: ImageStreamerProtocol, Instrumentable { // Double-check cache in case another task populated it while we were waiting to enter the actor if let cachedImage = self.cache.object(forKey: key) { if let inst = instrumentation { - Task.detached { await inst.notifyCacheHit() } + Task { await inst.notifyCacheHit() } } return cachedImage } @@ -122,28 +122,35 @@ public actor ImageStreamer: ImageStreamerProtocol, Instrumentable { activeTasks[key] = existingTaskInfo if let inst = instrumentation { - Task.detached { await inst.notifyCoalescedRequest() } + Task { await inst.notifyCoalescedRequest() } } let taskToAwait = existingTaskInfo.task return try await withTaskCancellationHandler { - let result = try await taskToAwait.value - try Task.checkCancellation() - return result + do { + let result = try await taskToAwait.value + try Task.checkCancellation() + return result + } catch { + // Surface this caller's cancellation as CancellationError rather than + // whatever the underlying fetch threw (e.g. URLError(.cancelled)). + try Task.checkCancellation() + throw error + } } onCancel: { [weak self] in Task { - await self?.handleCancellation(for: key) + await self?.handleCancellation(for: key, task: taskToAwait) } } } // No existing task - create the primary task if let inst = instrumentation { - Task.detached { await inst.notifyNetworkRequest() } + Task { await inst.notifyNetworkRequest() } } - let task = Task.detached { [weak self] () -> PlatformImage in + let task = Task { [weak self] () -> PlatformImage in guard let self else { throw CancellationError() } return try await self.fetchRemoteImage(url: url, pointSize: pointSize) } @@ -154,25 +161,35 @@ public actor ImageStreamer: ImageStreamerProtocol, Instrumentable { do { let result = try await task.value try Task.checkCancellation() - // Primary task completed successfully - clean up - activeTasks[key] = nil + // Primary task completed successfully - clean up. + // The entry may have been replaced by a newer request for the same key + // while we were suspended, so only remove it if it is still ours. + if activeTasks[key]?.task == task { + activeTasks[key] = nil + } return result } catch { - // Clean up on error - activeTasks[key] = nil + // Clean up on error, again only if the entry is still ours + if activeTasks[key]?.task == task { + activeTasks[key] = nil + } + // Surface this caller's cancellation as CancellationError rather than + // whatever the underlying fetch threw (e.g. URLError(.cancelled)). + try Task.checkCancellation() throw error } } onCancel: { [weak self] in Task { - await self?.handleCancellation(for: key) + await self?.handleCancellation(for: key, task: task) } } } - - - private func handleCancellation(for key: ImageCacheKey) { - guard var coalescedTask = activeTasks[key] else { return } + private func handleCancellation(for key: ImageCacheKey, task: Task) { + // This runs from an unstructured task at an arbitrary later time. The entry for + // this key may already belong to a newer request, so only act on the entry the + // cancelled caller was actually waiting on. + guard var coalescedTask = activeTasks[key], coalescedTask.task == task else { return } // Decrement the waiter count coalescedTask.waiterCount -= 1 @@ -181,10 +198,10 @@ public actor ImageStreamer: ImageStreamerProtocol, Instrumentable { // No more waiters, cancel the underlying task and remove it coalescedTask.task.cancel() activeTasks[key] = nil - + // Only track as cancelled when we actually cancel the network request if let inst = instrumentation { - Task.detached { await inst.notifyCancelledTask() } + Task { await inst.notifyCancelledTask() } } } else { // Still have waiters, keep the task alive - don't count as cancelled @@ -192,6 +209,7 @@ public actor ImageStreamer: ImageStreamerProtocol, Instrumentable { } } + @concurrent private nonisolated func fetchRemoteImage(url: URL, pointSize: CGSize?) async throws -> PlatformImage { try Task.checkCancellation() @@ -237,6 +255,7 @@ public actor ImageStreamer: ImageStreamerProtocol, Instrumentable { } } + @concurrent private nonisolated func downsample(imageData: Data, to pointSize: CGSize) async -> PlatformImage? { // Create an image source without decoding immediately let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary @@ -298,7 +317,7 @@ public extension ImageStreamer { // MARK: - Public instrumentation API -public protocol Instrumentable: Sendable, Actor { +public protocol Instrumentable: Actor { nonisolated var instrumentation: ImageStreamerInstrumentation? { get } @@ -324,11 +343,59 @@ extension Instrumentable { // MARK: - SwiftUI Environment Integration +/// Shared instances backing the environment defaults. `@Entry` expands its default +/// expression into a computed property that is re-evaluated on every fallback access, +/// so the defaults must reference stable shared instances instead of constructing new +/// ones inline. Sharing also keeps the default streamer wired to the default +/// instrumentation, so stats observers see its activity. +private enum ImageStreamerDefaults { + static let instrumentation = StandardImageStreamerInstrumentation() + static let streamer = ImageStreamer(instrumentation: instrumentation) +} + public extension EnvironmentValues { - @Entry var imageStreamer: ImageStreamerProtocol = ImageStreamer() - @Entry var instrumentation: ImageStreamerInstrumentation? = StandardImageStreamerInstrumentation() + @Entry var imageStreamer: ImageStreamerProtocol = ImageStreamerDefaults.streamer + @Entry var instrumentation: ImageStreamerInstrumentation? = ImageStreamerDefaults.instrumentation } -// Retroactive conformance to allow usage in nonisolated contexts. -// NSCache is thread-safe documentation-wise. -extension NSCache: @retroactive @unchecked Sendable {} +// MARK: - Image Cache + +/// A thread-safe in-memory image cache backed by `NSCache`. +/// +/// Owning this wrapper lets the library vouch for its sendability (`NSCache` is +/// documented thread-safe) without declaring a retroactive `Sendable` conformance +/// on `NSCache` itself, which would apply process-wide to every module. +public final class ImageCache: @unchecked Sendable { + + private let storage = NSCache() + + public init() {} + + /// The maximum total cost the cache can hold before it starts evicting objects. + public var totalCostLimit: Int { + get { storage.totalCostLimit } + set { storage.totalCostLimit = newValue } + } + + /// The maximum number of images the cache should hold. + public var countLimit: Int { + get { storage.countLimit } + set { storage.countLimit = newValue } + } + + public func object(forKey key: ImageCacheKey) -> PlatformImage? { + storage.object(forKey: key) + } + + public func setObject(_ image: PlatformImage, forKey key: ImageCacheKey, cost: Int) { + storage.setObject(image, forKey: key, cost: cost) + } + + public func removeObject(forKey key: ImageCacheKey) { + storage.removeObject(forKey: key) + } + + public func removeAllObjects() { + storage.removeAllObjects() + } +} diff --git a/Sources/ImageStreamer/ImageStreamerStats.swift b/Sources/ImageStreamer/ImageStreamerStats.swift index 8f2f8ae..0f6b39c 100644 --- a/Sources/ImageStreamer/ImageStreamerStats.swift +++ b/Sources/ImageStreamer/ImageStreamerStats.swift @@ -22,7 +22,7 @@ public struct ImageStreamerStats: Sendable, Equatable { // MARK: - Instrumentation Protocol /// Protocol defining the interface for collecting ImageStreamer statistics. -public protocol ImageStreamerInstrumentation: Sendable, Actor { +public protocol ImageStreamerInstrumentation: Actor { /// Notifies that a cache hit occurred. func notifyCacheHit() /// Notifies that a network request was started. @@ -52,21 +52,13 @@ public actor StandardImageStreamerInstrumentation: ImageStreamerInstrumentation /// Active continuations for stats observers private var statsContinuations: [UUID: AsyncStream.Continuation] = [:] - - /// Throttling logic - private var needsBroadcast = false - - public init() { - // Start a throttling loop to prevent excessive UI updates - Task { [weak self] in - while !Task.isCancelled { - try? await Task.sleep(for: .milliseconds(100)) // 10Hz throttle - guard let self else { return } - await self.flushStats() - } - } - } - + + /// Whether a throttled broadcast is already scheduled. Updates within the throttle + /// window coalesce into the single pending flush. + private var flushScheduled = false + + public init() {} + public var currentStats: ImageStreamerStats { ImageStreamerStats( cacheHits: cacheHits, @@ -75,60 +67,77 @@ public actor StandardImageStreamerInstrumentation: ImageStreamerInstrumentation cancelledTasks: cancelledTasks ) } - + /// Returns an `AsyncStream` that emits stats updates whenever they change. /// The stream automatically terminates when the consuming task is cancelled. + /// Observers only care about the latest snapshot, so older buffered values are dropped. public var statsStream: AsyncStream { - AsyncStream { continuation in - let id = UUID() - - // Emit the current stats immediately - continuation.yield(self.currentStats) - - // Store the continuation so we can yield future updates - self.statsContinuations[id] = continuation - - // Clean up when the stream is terminated - continuation.onTermination = { @Sendable _ in - Task { await self.removeStatsContinuation(id: id) } - } + let (stream, continuation) = AsyncStream.makeStream( + of: ImageStreamerStats.self, + bufferingPolicy: .bufferingNewest(1) + ) + let id = UUID() + + // Emit the current stats immediately + continuation.yield(currentStats) + + // Store the continuation so we can yield future updates + statsContinuations[id] = continuation + + // Clean up when the stream is terminated + continuation.onTermination = { [weak self] _ in + Task { await self?.removeStatsContinuation(id: id) } } + + return stream } - + private func removeStatsContinuation(id: UUID) { statsContinuations.removeValue(forKey: id) } - - /// Broadcasts the current stats to all active observers if pending updates exist + + /// Schedules a throttled broadcast (at most one in flight) to prevent excessive UI updates. + private func scheduleFlush() { + guard !flushScheduled else { return } + flushScheduled = true + + Task { [weak self] in + try? await Task.sleep(for: .milliseconds(100)) // 10Hz throttle + await self?.performScheduledFlush() + } + } + + private func performScheduledFlush() { + flushScheduled = false + flushStats() + } + + /// Broadcasts the current stats to all active observers private func flushStats() { - guard needsBroadcast else { return } - let stats = currentStats for continuation in statsContinuations.values { continuation.yield(stats) } - - needsBroadcast = false } - + public func notifyCacheHit() { cacheHits += 1 - needsBroadcast = true + scheduleFlush() } - + public func notifyNetworkRequest() { networkRequests += 1 - needsBroadcast = true + scheduleFlush() } - + public func notifyCoalescedRequest() { coalescedRequests += 1 - needsBroadcast = true + scheduleFlush() } - + public func notifyCancelledTask() { cancelledTasks += 1 - needsBroadcast = true + scheduleFlush() } public func reset() { @@ -136,8 +145,7 @@ public actor StandardImageStreamerInstrumentation: ImageStreamerInstrumentation networkRequests = 0 coalescedRequests = 0 cancelledTasks = 0 - needsBroadcast = true - // Force flush immediately on reset for responsiveness + // Flush immediately on reset for responsiveness flushStats() } } diff --git a/Tests/ImageStreamerTests/ImageStreamerFormatTests.swift b/Tests/ImageStreamerTests/ImageStreamerFormatTests.swift index ef22de9..be60ede 100644 --- a/Tests/ImageStreamerTests/ImageStreamerFormatTests.swift +++ b/Tests/ImageStreamerTests/ImageStreamerFormatTests.swift @@ -2,22 +2,7 @@ import Testing import Foundation @testable import ImageStreamer -#if canImport(UIKit) -import UIKit -#elseif canImport(AppKit) -import AppKit -#endif - -/// Cross-platform helper to extract a CGImage from a PlatformImage. -private func extractCGImage(from image: PlatformImage) -> CGImage? { - #if canImport(UIKit) - return image.cgImage - #elseif canImport(AppKit) - return image.cgImage(forProposedRect: nil, context: nil, hints: nil) - #else - return nil - #endif -} +// `extractCGImage(from:)` is a shared cross-platform test helper defined in Mocks/MockImageFetcher.swift. @Suite("ImageStreamer Format Support") struct ImageStreamerFormatTests { diff --git a/Tests/ImageStreamerTests/ImageStreamerTests.swift b/Tests/ImageStreamerTests/ImageStreamerTests.swift index 9db075f..cf2271f 100644 --- a/Tests/ImageStreamerTests/ImageStreamerTests.swift +++ b/Tests/ImageStreamerTests/ImageStreamerTests.swift @@ -17,20 +17,28 @@ struct ImageStreamerCoreTests { let image = try await streamer.image(for: url) - #expect(image.cgImage != nil) + #expect(extractCGImage(from: image) != nil) } @Test("Loads and downsamples image to specified point size") func loadsDownsampledImage() async throws { let url = URL(string: "https://example.com/large.png")! - let imageData = MockImageData.validPNGData() + let sourceDimension = 512 + let imageData = MockImageData.largePNGData(dimension: sourceDimension) let response = MockImageData.successResponse(for: url) let (streamer, _) = makeStreamer(result: .success((imageData, response))) let image = try await streamer.image(for: url, pointSize: CGSize(width: 100, height: 100)) - #expect(image.cgImage != nil) + // The downsample path must actually shrink the 512x512 source - a result still + // at the source size would mean downsampling silently did nothing. + let cgImage = try #require(extractCGImage(from: image)) + #expect( + max(cgImage.width, cgImage.height) < sourceDimension, + "Downsampled image should be smaller than the \(sourceDimension)px source, got \(cgImage.width)x\(cgImage.height)" + ) + #expect(cgImage.width > 0 && cgImage.height > 0) } @Test("Throws invalidImageData error for corrupt data") @@ -54,9 +62,10 @@ struct ImageStreamerCoreTests { let (streamer, _) = makeStreamer(result: .success((imageData, response))) - await #expect(throws: URLError.self) { + let error = await #expect(throws: URLError.self) { _ = try await streamer.image(for: url) } + #expect(error?.code == .badServerResponse) } @Test("Propagates network errors from the session") @@ -66,9 +75,10 @@ struct ImageStreamerCoreTests { let (streamer, _) = makeStreamer(result: .failure(networkError)) - await #expect(throws: URLError.self) { + let error = await #expect(throws: URLError.self) { _ = try await streamer.image(for: url) } + #expect(error?.code == .notConnectedToInternet) } } @@ -207,19 +217,29 @@ struct ImageStreamerCoalescingTests { let response = MockImageData.successResponse(for: url) let requestTracker = RequestTracker() - let (streamer, _) = makeStreamer( + let instrumentation = StandardImageStreamerInstrumentation() + let (streamer, gate, _) = makeGatedStreamer( result: .success((imageData, response)), - delay: .milliseconds(200), // Longer delay to ensure coalescing - requestTracker: requestTracker + requestTracker: requestTracker, + instrumentation: instrumentation ) - // Start multiple concurrent requests - async let image1 = streamer.image(for: url) - async let image2 = streamer.image(for: url) - async let image3 = streamer.image(for: url) + // Start multiple concurrent requests; the primary blocks on the gate + let tasks = (0..<3).map { _ in + Task { try await streamer.image(for: url) } + } + + // Wait until both secondary requests have actually joined the primary, + // then release the fetch + try await waitForStats(instrumentation) { stats in + stats.networkRequests == 1 && stats.coalescedRequests == 2 + } + await gate.open() // All should complete without throwing - _ = try await (image1, image2, image3) + for task in tasks { + _ = try await task.value + } // But only one network request should have been made let totalRequests = await requestTracker.requestCount(for: url) @@ -234,9 +254,10 @@ struct ImageStreamerCoalescingTests { let response = MockImageData.successResponse(for: url) let requestTracker = RequestTracker() + // No delay needed: these requests have distinct cache keys (different point + // sizes), so they can never coalesce regardless of timing. let (streamer, _) = makeStreamer( result: .success((imageData, response)), - delay: .milliseconds(100), requestTracker: requestTracker ) @@ -259,10 +280,11 @@ struct ImageStreamerCoalescingTests { let response = MockImageData.successResponse(for: url) let requestTracker = RequestTracker() - let (streamer, _) = makeStreamer( + let instrumentation = StandardImageStreamerInstrumentation() + let (streamer, gate, _) = makeGatedStreamer( result: .success((invalidData, response)), - delay: .milliseconds(100), - requestTracker: requestTracker + requestTracker: requestTracker, + instrumentation: instrumentation ) // Start multiple concurrent requests that will all fail @@ -270,6 +292,12 @@ struct ImageStreamerCoalescingTests { let task2 = Task { try await streamer.image(for: url) } let task3 = Task { try await streamer.image(for: url) } + // Wait until both secondary requests have joined the primary before failing it + try await waitForStats(instrumentation) { stats in + stats.networkRequests == 1 && stats.coalescedRequests == 2 + } + await gate.open() + // All should throw the same error await #expect(throws: ImageStreamerError.invalidImageData) { _ = try await task1.value @@ -294,9 +322,8 @@ struct ImageStreamerCoalescingTests { let instrumentation = StandardImageStreamerInstrumentation() let requestTracker = RequestTracker() - let (streamer, _) = makeStreamer( + let (streamer, gate, _) = makeGatedStreamer( result: .success((imageData, response)), - delay: .milliseconds(200), requestTracker: requestTracker, instrumentation: instrumentation ) @@ -306,19 +333,26 @@ struct ImageStreamerCoalescingTests { Task { try await streamer.image(for: url) } } - // All should complete successfully without throwing + // Wait until all 4 secondary requests have joined the primary, then release it + try await waitForStats(instrumentation) { stats in + stats.networkRequests == 1 && stats.coalescedRequests == 4 + } + await gate.open() + + // Every waiter must receive the exact same image instance produced by the + // single shared fetch - that is the whole point of coalescing. + var images: [PlatformImage] = [] for task in tasks { - _ = try await task.value + images.append(try await task.value) + } + let first = try #require(images.first) + for image in images.dropFirst() { + #expect(image === first, "All coalesced waiters should receive the same image instance") } // Only one network request let totalRequests = await requestTracker.requestCount(for: url) #expect(totalRequests == 1) - - // Should have 1 network request + 4 coalesced requests - try await waitForStats(instrumentation) { stats in - stats.networkRequests == 1 && stats.coalescedRequests == 4 - } } @Test("Subsequent request after coalesced batch completes triggers new network request") @@ -328,12 +362,10 @@ struct ImageStreamerCoalescingTests { let response = MockImageData.successResponse(for: url) let requestTracker = RequestTracker() - let instrumentation = StandardImageStreamerInstrumentation() let (streamer, cache) = makeStreamer( result: .success((imageData, response)), delay: .milliseconds(100), - requestTracker: requestTracker, - instrumentation: instrumentation + requestTracker: requestTracker ) // First batch of coalesced requests @@ -363,21 +395,26 @@ struct ImageStreamerCancellationTests { let imageData = MockImageData.validPNGData() let response = MockImageData.successResponse(for: url) - let (streamer, _) = makeStreamer( + let instrumentation = StandardImageStreamerInstrumentation() + let (streamer, gate, _) = makeGatedStreamer( result: .success((imageData, response)), - delay: .seconds(10) // Long delay to ensure we can cancel + instrumentation: instrumentation ) let task = Task { try await streamer.image(for: url) } - // Give the task time to start - try await Task.sleep(for: .milliseconds(50)) - - // Cancel the task + // Wait until the fetch is genuinely in flight (parked on the gate) before cancelling + try await waitForStats(instrumentation) { $0.networkRequests == 1 } task.cancel() + // The sole waiter cancelling must tear down the underlying network task + try await waitForStats(instrumentation) { $0.cancelledTasks == 1 } + + // Release the gate so the cancelled fetch can unwind + await gate.open() + // Should throw CancellationError do { _ = try await task.value @@ -396,10 +433,11 @@ struct ImageStreamerCancellationTests { let response = MockImageData.successResponse(for: url) let requestTracker = RequestTracker() - let (streamer, _) = makeStreamer( + let instrumentation = StandardImageStreamerInstrumentation() + let (streamer, gate, _) = makeGatedStreamer( result: .success((imageData, response)), - delay: .seconds(10), // Long delay to ensure we can cancel - requestTracker: requestTracker + requestTracker: requestTracker, + instrumentation: instrumentation ) // Start multiple concurrent requests that will be coalesced @@ -413,14 +451,16 @@ struct ImageStreamerCancellationTests { try await streamer.image(for: url) } - // Give tasks time to start and coalesce - try await Task.sleep(for: .milliseconds(50)) - - // Cancel all tasks + // Wait until both secondaries have joined the primary, then cancel all of them + try await waitForStats(instrumentation) { $0.networkRequests == 1 && $0.coalescedRequests == 2 } task1.cancel() task2.cancel() task3.cancel() + // Once every waiter is gone, the underlying network task must be cancelled + try await waitForStats(instrumentation) { $0.cancelledTasks == 1 } + await gate.open() + // All should throw CancellationError for (index, task) in [task1, task2, task3].enumerated() { do { @@ -444,9 +484,10 @@ struct ImageStreamerCancellationTests { let imageData = MockImageData.validPNGData() let response = MockImageData.successResponse(for: url) - let (streamer, cache) = makeStreamer( + let instrumentation = StandardImageStreamerInstrumentation() + let (streamer, gate, cache) = makeGatedStreamer( result: .success((imageData, response)), - delay: .seconds(10) + instrumentation: instrumentation ) let cacheKey = ImageCacheKey(url: url, pointSize: nil) @@ -455,8 +496,12 @@ struct ImageStreamerCancellationTests { try await streamer.image(for: url) } - try await Task.sleep(for: .milliseconds(50)) + // Hold the fetch on the gate, then cancel and wait for the underlying task to be cancelled + // *before* opening the gate, so the fetch sees the cancellation and never reaches the cache write. + try await waitForStats(instrumentation) { $0.networkRequests == 1 } task.cancel() + try await waitForStats(instrumentation) { $0.cancelledTasks == 1 } + await gate.open() // Wait for cancellation to complete _ = try? await task.value @@ -471,24 +516,27 @@ struct ImageStreamerCancellationTests { let imageData = MockImageData.validPNGData() let response = MockImageData.successResponse(for: url) - let (streamer, _) = makeStreamer( + let instrumentation = StandardImageStreamerInstrumentation() + let (streamer, gate, _) = makeGatedStreamer( result: .success((imageData, response)), - delay: .milliseconds(200) + instrumentation: instrumentation ) - // Start two concurrent requests + // task1 becomes the primary and parks on the gate let task1 = Task { try await streamer.image(for: url) } + try await waitForStats(instrumentation) { $0.networkRequests == 1 } + + // task2 joins as a secondary waiter let task2 = Task { try await streamer.image(for: url) } + try await waitForStats(instrumentation) { $0.coalescedRequests == 1 } - // Give tasks time to coalesce - try await Task.sleep(for: .milliseconds(50)) - - // Cancel only the first task + // Cancel only the first task; task2 keeps the shared fetch alive task1.cancel() + await gate.open() // The second task should still complete successfully _ = try await task2.value @@ -502,25 +550,26 @@ struct ImageStreamerCancellationTests { let response = MockImageData.successResponse(for: url) let instrumentation = StandardImageStreamerInstrumentation() - let (streamer, _) = makeStreamer( + let (streamer, gate, _) = makeGatedStreamer( result: .success((imageData, response)), - delay: .milliseconds(300), instrumentation: instrumentation ) - // Start two concurrent requests + // task1 becomes the primary and parks on the gate let task1 = Task { try await streamer.image(for: url) } + try await waitForStats(instrumentation) { $0.networkRequests == 1 } + + // task2 joins as a secondary waiter let task2 = Task { try await streamer.image(for: url) } + try await waitForStats(instrumentation) { $0.coalescedRequests == 1 } - // Give tasks time to coalesce - try await Task.sleep(for: .milliseconds(50)) - - // Cancel only the first task + // Cancel only the first task; task2 keeps the request alive task1.cancel() + await gate.open() // Wait for cancellation to propagate (ignoring error) _ = try? await task1.value @@ -541,27 +590,23 @@ struct ImageStreamerCancellationTests { let response = MockImageData.successResponse(for: url) let instrumentation = StandardImageStreamerInstrumentation() - let (streamer, _) = makeStreamer( + let (streamer, gate, _) = makeGatedStreamer( result: .success((imageData, response)), - delay: .milliseconds(300), instrumentation: instrumentation ) - // Start primary task + // Start primary task and wait until it owns the in-flight fetch let primaryTask = Task { try await streamer.image(for: url) } - - // Small delay to ensure primaryTask becomes the primary - try await Task.sleep(for: .milliseconds(20)) - - // Start secondary waiters + try await waitForStats(instrumentation) { $0.networkRequests == 1 } + + // Start secondary waiters and wait until both have joined the primary let secondaryTask1 = Task { try await streamer.image(for: url) } let secondaryTask2 = Task { try await streamer.image(for: url) } + try await waitForStats(instrumentation) { $0.coalescedRequests == 2 } - // Give all tasks time to coalesce - try await Task.sleep(for: .milliseconds(50)) - - // Cancel the primary task + // Cancel the primary task; the two secondaries keep the fetch alive primaryTask.cancel() + await gate.open() // Secondary tasks should still complete successfully _ = try await secondaryTask1.value @@ -579,9 +624,8 @@ struct ImageStreamerCancellationTests { let response = MockImageData.successResponse(for: url) let instrumentation = StandardImageStreamerInstrumentation() - let (streamer, _) = makeStreamer( + let (streamer, gate, _) = makeGatedStreamer( result: .success((imageData, response)), - delay: .seconds(10), // Long delay to ensure we can cancel all instrumentation: instrumentation ) @@ -590,23 +634,24 @@ struct ImageStreamerCancellationTests { Task { try await streamer.image(for: url) } } - // Give all tasks time to coalesce - try await Task.sleep(for: .milliseconds(100)) + // Wait until 1 primary + 4 coalesced waiters are all established + try await waitForStats(instrumentation) { $0.networkRequests == 1 && $0.coalescedRequests == 4 } // Cancel all tasks for task in tasks { task.cancel() } - // Wait for all cancellations to propagate - for task in tasks { - _ = try? await task.value - } - // Should have exactly 1 cancelled task tracked (the underlying network request) try await waitForStats(instrumentation) { stats in stats.cancelledTasks == 1 } + + // Release the gate so the cancelled fetch can unwind + await gate.open() + for task in tasks { + _ = try? await task.value + } } @Test("Late joiner after some cancellations still receives result") @@ -615,29 +660,73 @@ struct ImageStreamerCancellationTests { let imageData = MockImageData.validPNGData() let response = MockImageData.successResponse(for: url) - let (streamer, _) = makeStreamer( + let instrumentation = StandardImageStreamerInstrumentation() + let (streamer, gate, _) = makeGatedStreamer( result: .success((imageData, response)), - delay: .milliseconds(300) + instrumentation: instrumentation ) - // Start initial tasks + // task1 becomes the primary; task2 joins it let task1 = Task { try await streamer.image(for: url) } + try await waitForStats(instrumentation) { $0.networkRequests == 1 } let task2 = Task { try await streamer.image(for: url) } + try await waitForStats(instrumentation) { $0.coalescedRequests == 1 } - // Give tasks time to coalesce - try await Task.sleep(for: .milliseconds(50)) - - // Cancel one task + // Cancel task1; task2 keeps the shared fetch alive task1.cancel() - _ = try? await task1.value - // Start a late joiner while task2 is still waiting + // A late joiner arrives while task2 is still waiting and coalesces onto the same fetch let lateJoiner = Task { try await streamer.image(for: url) } + try await waitForStats(instrumentation) { $0.coalescedRequests == 2 } - // Both remaining tasks should complete successfully + // Release the fetch; both remaining waiters should complete successfully + await gate.open() _ = try await task2.value _ = try await lateJoiner.value } + + @Test("A cancelled primary's cleanup does not tear down a replacement entry for the same key") + func cancelledPrimaryDoesNotRemoveReplacementEntry() async throws { + let url = URL(string: "https://example.com/replace.png")! + let imageData = MockImageData.validPNGData() + let response = MockImageData.successResponse(for: url) + + let requestTracker = RequestTracker() + let instrumentation = StandardImageStreamerInstrumentation() + let (streamer, gate, _) = makeGatedStreamer( + result: .success((imageData, response)), + requestTracker: requestTracker, + instrumentation: instrumentation + ) + + // Request A becomes the primary and parks on the gate + let requestA = Task { try await streamer.image(for: url) } + try await waitForStats(instrumentation) { $0.networkRequests == 1 } + + // Cancelling the sole waiter tears down A's entry and cancels its underlying task, + // but the orphaned fetch is still parked on the (shared) gate. + requestA.cancel() + try await waitForStats(instrumentation) { $0.cancelledTasks == 1 } + + // Request B installs a brand-new primary entry under the same key + let requestB = Task { try await streamer.image(for: url) } + try await waitForStats(instrumentation) { $0.networkRequests == 2 } + + // A late joiner must coalesce onto B's entry - proving B's entry is intact and was + // not removed by A's (cancelled) cleanup running later under the same key. + let requestC = Task { try await streamer.image(for: url) } + try await waitForStats(instrumentation) { $0.coalescedRequests == 1 } + + // Release everything: A unwinds as cancelled, B and C share the second fetch + await gate.open() + _ = try? await requestA.value + _ = try await requestB.value + _ = try await requestC.value + + // Exactly two underlying fetches: A (cancelled) and B (shared by C, not a third fetch) + let total = await requestTracker.requestCount(for: url) + #expect(total == 2, "Late joiner should coalesce onto the replacement entry, not start a third fetch") + } } // MARK: - Convenience API Tests @@ -654,7 +743,8 @@ struct ImageStreamerConvenienceTests { let (streamer, _) = makeStreamer(result: .success((imageData, response))) - _ = try await streamer.image(for: urlString) + let image = try await streamer.image(for: urlString) + #expect(extractCGImage(from: image) != nil) } @Test("Throws invalidURL for bad URL strings", arguments: [ @@ -679,7 +769,8 @@ struct ImageStreamerConvenienceTests { let (streamer, _) = makeStreamer(result: .success((imageData, response))) - _ = try await streamer.image(for: urlString) + let image = try await streamer.image(for: urlString) + #expect(extractCGImage(from: image) != nil) } } @@ -696,8 +787,9 @@ struct ImageStreamerHTTPStatusTests { let (streamer, _) = makeStreamer(result: .success((imageData, response))) - // Should not throw for 2xx status codes - _ = try await streamer.image(for: url) + // Should not throw for 2xx status codes, and should return a usable image + let image = try await streamer.image(for: url) + #expect(extractCGImage(from: image) != nil) } @Test("Rejects 4xx client error status codes", arguments: [400, 401, 403, 404, 405, 408, 429]) @@ -708,9 +800,10 @@ struct ImageStreamerHTTPStatusTests { let (streamer, _) = makeStreamer(result: .success((imageData, response))) - await #expect(throws: URLError.self) { + let error = await #expect(throws: URLError.self) { _ = try await streamer.image(for: url) } + #expect(error?.code == .badServerResponse) } @Test("Rejects 5xx server error status codes", arguments: [500, 501, 502, 503, 504]) @@ -721,9 +814,10 @@ struct ImageStreamerHTTPStatusTests { let (streamer, _) = makeStreamer(result: .success((imageData, response))) - await #expect(throws: URLError.self) { + let error = await #expect(throws: URLError.self) { _ = try await streamer.image(for: url) } + #expect(error?.code == .badServerResponse) } @Test("Rejects 3xx redirect status codes", arguments: [301, 302, 303, 307, 308]) @@ -734,9 +828,10 @@ struct ImageStreamerHTTPStatusTests { let (streamer, _) = makeStreamer(result: .success((imageData, response))) - await #expect(throws: URLError.self) { + let error = await #expect(throws: URLError.self) { _ = try await streamer.image(for: url) } + #expect(error?.code == .badServerResponse) } @Test("Rejects 1xx informational status codes", arguments: [100, 101, 102]) @@ -747,9 +842,10 @@ struct ImageStreamerHTTPStatusTests { let (streamer, _) = makeStreamer(result: .success((imageData, response))) - await #expect(throws: URLError.self) { + let error = await #expect(throws: URLError.self) { _ = try await streamer.image(for: url) } + #expect(error?.code == .badServerResponse) } } @@ -800,23 +896,25 @@ struct ImageStreamerStatsTests { let response = MockImageData.successResponse(for: url) let instrumentation = StandardImageStreamerInstrumentation() - let (streamer, _) = makeStreamer( + let (streamer, gate, _) = makeGatedStreamer( result: .success((imageData, response)), - delay: .milliseconds(200), instrumentation: instrumentation ) - // Start multiple concurrent requests - async let image1 = streamer.image(for: url) - async let image2 = streamer.image(for: url) - async let image3 = streamer.image(for: url) - - _ = try await (image1, image2, image3) + // Start multiple concurrent requests; the primary blocks on the gate + let tasks = (0..<3).map { _ in + Task { try await streamer.image(for: url) } + } // One network request, two coalesced requests try await waitForStats(instrumentation) { stats in stats.networkRequests == 1 && stats.coalescedRequests == 2 } + await gate.open() + + for task in tasks { + _ = try await task.value + } } @Test("Resets statistics to zero") @@ -890,12 +988,19 @@ struct ImageStreamerEdgeCaseTests { ) func handlesVariousPointSizes(pointSize: CGSize) async throws { let url = URL(string: "https://example.com/image.png")! - let imageData = MockImageData.validPNGData() + let sourceDimension = 512 + let imageData = MockImageData.largePNGData(dimension: sourceDimension) let response = MockImageData.successResponse(for: url) let (streamer, _) = makeStreamer(result: .success((imageData, response))) - _ = try await streamer.image(for: url, pointSize: pointSize) + let image = try await streamer.image(for: url, pointSize: pointSize) + + // Regardless of the requested size, downsampling must never upscale beyond the + // source - even when the requested size (e.g. 10000x10000) exceeds it. + let cgImage = try #require(extractCGImage(from: image)) + #expect(cgImage.width <= sourceDimension && cgImage.height <= sourceDimension) + #expect(cgImage.width > 0 && cgImage.height > 0) } @Test("Same URL with different query parameters are cached separately") @@ -943,7 +1048,8 @@ struct ImageStreamerEdgeCaseTests { let (streamer, _) = makeStreamer(result: .success((imageData, response))) // Should still work since non-HTTP responses pass the status check - _ = try await streamer.image(for: url) + let image = try await streamer.image(for: url) + #expect(extractCGImage(from: image) != nil) } } @@ -1011,6 +1117,75 @@ struct ImageCacheKeyTests { } } +// MARK: - ImageCache Tests + +@Suite("ImageCache") +struct ImageCacheTests { + + private func makeImage() throws -> PlatformImage { + try #require(PlatformImage(data: MockImageData.validPNGData())) + } + + @Test("Stores and retrieves an image by key") + func storesAndRetrievesImage() throws { + let cache = ImageCache() + let key = ImageCacheKey(url: URL(string: "https://example.com/a.png")!, pointSize: nil) + let image = try makeImage() + + cache.setObject(image, forKey: key, cost: 1) + + #expect(cache.object(forKey: key) === image) + } + + @Test("Returns nil for an unknown key") + func returnsNilForUnknownKey() { + let cache = ImageCache() + let key = ImageCacheKey(url: URL(string: "https://example.com/missing.png")!, pointSize: nil) + + #expect(cache.object(forKey: key) == nil) + } + + @Test("Removes a single entry") + func removesSingleEntry() throws { + let cache = ImageCache() + let key = ImageCacheKey(url: URL(string: "https://example.com/a.png")!, pointSize: nil) + cache.setObject(try makeImage(), forKey: key, cost: 1) + + cache.removeObject(forKey: key) + + #expect(cache.object(forKey: key) == nil) + } + + @Test("Removes all entries") + func removesAllEntries() throws { + let cache = ImageCache() + let key1 = ImageCacheKey(url: URL(string: "https://example.com/a.png")!, pointSize: nil) + let key2 = ImageCacheKey(url: URL(string: "https://example.com/b.png")!, pointSize: nil) + let image = try makeImage() + cache.setObject(image, forKey: key1, cost: 1) + cache.setObject(image, forKey: key2, cost: 1) + + cache.removeAllObjects() + + #expect(cache.object(forKey: key1) == nil) + #expect(cache.object(forKey: key2) == nil) + } + + // The wrapper forwards these limits to NSCache. We only assert the values round-trip: + // NSCache treats both as advisory ("not a strict limit"), so it may evict immediately, + // later, or never - asserting actual eviction here would be inherently flaky. + @Test("Cost and count limits round-trip through the wrapper") + func limitsRoundTrip() { + let cache = ImageCache() + + cache.totalCostLimit = 5_000_000 + cache.countLimit = 42 + + #expect(cache.totalCostLimit == 5_000_000) + #expect(cache.countLimit == 42) + } +} + // MARK: - Test Helpers /// Creates a configured ImageStreamer with its dependencies for testing. @@ -1019,13 +1194,13 @@ func makeStreamer( delay: Duration? = nil, requestTracker: RequestTracker? = nil, instrumentation: ImageStreamerInstrumentation? = nil -) -> (streamer: ImageStreamer, cache: NSCache) { +) -> (streamer: ImageStreamer, cache: ImageCache) { let mockFetcher = MockImageFetcher( result: result, delay: delay, requestTracker: requestTracker ) - let cache = NSCache() + let cache = ImageCache() let streamer = ImageStreamer( session: mockFetcher, cache: cache, @@ -1040,13 +1215,13 @@ func makeStreamer( delay: Duration? = nil, requestTracker: RequestTracker? = nil, instrumentation: ImageStreamerInstrumentation? = nil -) -> (streamer: ImageStreamer, cache: NSCache) { +) -> (streamer: ImageStreamer, cache: ImageCache) { let mockFetcher = URLMappingMockFetcher( responses: responses, delay: delay, requestTracker: requestTracker ) - let cache = NSCache() + let cache = ImageCache() let streamer = ImageStreamer( session: mockFetcher, cache: cache, @@ -1055,6 +1230,28 @@ func makeStreamer( return (streamer, cache) } +/// Creates a configured ImageStreamer whose fetches block until the returned gate is opened. +/// Use this (instead of fixed delays) when a test must guarantee that requests overlap. +func makeGatedStreamer( + result: Result<(Data, URLResponse), Error>, + requestTracker: RequestTracker? = nil, + instrumentation: ImageStreamerInstrumentation? = nil +) -> (streamer: ImageStreamer, gate: RequestGate, cache: ImageCache) { + let gate = RequestGate() + let cache = ImageCache() + let mockFetcher = GatedMockFetcher( + result: result, + gate: gate, + requestTracker: requestTracker + ) + let streamer = ImageStreamer( + session: mockFetcher, + cache: cache, + instrumentation: instrumentation + ) + return (streamer, gate, cache) +} + /// Helper to wait for async stats updates with timeout. private func waitForStats( _ instrumentation: StandardImageStreamerInstrumentation, diff --git a/Tests/ImageStreamerTests/Mocks/GatedMockFetcher.swift b/Tests/ImageStreamerTests/Mocks/GatedMockFetcher.swift new file mode 100644 index 0000000..8aaee11 --- /dev/null +++ b/Tests/ImageStreamerTests/Mocks/GatedMockFetcher.swift @@ -0,0 +1,55 @@ +import ImageStreamer +import Foundation + +// MARK: - Request Gate + +/// An async gate that suspends callers until the test explicitly opens it. +/// +/// Used to make coalescing deterministic: requests block inside the fetcher until +/// the test has verified that all expected waiters joined, then the gate is opened. +actor RequestGate { + private var isOpen = false + private var waiters: [CheckedContinuation] = [] + + /// Suspends until `open()` is called. Returns immediately if already open. + func wait() async { + if isOpen { return } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + /// Releases all current and future waiters. + func open() { + isOpen = true + for waiter in waiters { + waiter.resume() + } + waiters.removeAll() + } +} + +// MARK: - Gated Mock Fetcher + +/// A mock fetcher whose requests block on a `RequestGate` until the test releases them. +/// +/// Unlike a fixed `delay`, this guarantees the primary request is still in flight while +/// secondary requests join it, so coalescing assertions don't depend on scheduler timing. +struct GatedMockFetcher: ImageFetching { + let result: Result<(Data, URLResponse), Error> + let gate: RequestGate + let requestTracker: RequestTracker? + + func data(from url: URL) async throws -> (Data, URLResponse) { + await requestTracker?.recordRequest(for: url) + + await gate.wait() + + switch result { + case .success(let tuple): + return tuple + case .failure(let error): + throw error + } + } +} diff --git a/Tests/ImageStreamerTests/Mocks/MockImageFetcher.swift b/Tests/ImageStreamerTests/Mocks/MockImageFetcher.swift index bd10f07..21ee884 100644 --- a/Tests/ImageStreamerTests/Mocks/MockImageFetcher.swift +++ b/Tests/ImageStreamerTests/Mocks/MockImageFetcher.swift @@ -1,5 +1,27 @@ import ImageStreamer import Foundation +import CoreGraphics +import ImageIO + +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +/// Cross-platform helper to extract a `CGImage` from a `PlatformImage`. +/// +/// `UIImage.cgImage` is a stored optional, but `NSImage` exposes a `cgImage(forProposedRect:context:hints:)` +/// method instead - so tests must not touch `image.cgImage` directly if they are to build on every platform. +func extractCGImage(from image: PlatformImage) -> CGImage? { + #if canImport(UIKit) + return image.cgImage + #elseif canImport(AppKit) + return image.cgImage(forProposedRect: nil, context: nil, hints: nil) + #else + return nil + #endif +} // MARK: - Mock ImageFetcher for Testing @@ -81,6 +103,42 @@ enum MockImageData { return Data(pngData) } + /// Creates a solid-color PNG of the given square `dimension` (default 512×512). + /// + /// Unlike `validPNGData()` (a 1×1 pixel), this is large enough to actually be + /// downsampled, so tests can assert that the downsample path produces a smaller + /// image instead of just checking that *some* image came back. + static func largePNGData(dimension: Int = 512) -> Data { + let colorSpace = CGColorSpaceCreateDeviceRGB() + guard let context = CGContext( + data: nil, + width: dimension, + height: dimension, + bitsPerComponent: 8, + bytesPerRow: dimension * 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + return Data() + } + + context.setFillColor(red: 1, green: 0, blue: 0, alpha: 1) + context.fill(CGRect(x: 0, y: 0, width: dimension, height: dimension)) + + guard let cgImage = context.makeImage() else { return Data() } + + let encoded = NSMutableData() + guard let destination = CGImageDestinationCreateWithData( + encoded, "public.png" as CFString, 1, nil + ) else { + return Data() + } + CGImageDestinationAddImage(destination, cgImage, nil) + guard CGImageDestinationFinalize(destination) else { return Data() } + + return encoded as Data + } + /// Creates valid JPEG image data for testing. static func validJPEGData() -> Data { let base64 = "/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////wgALCAABAAEBAREA/8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQABPxA="