Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<ImageCacheKey, PlatformImage>`. 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
Expand Down
48 changes: 48 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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`.
4 changes: 2 additions & 2 deletions Documentation/ADVANCED.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -66,7 +66,7 @@ The entire `ImageStreamer` sits behind protocols (`ImageStreamerProtocol`, `Imag
```swift
public init(
session: ImageFetching = URLSession.shared,
cache: NSCache<ImageCacheKey, PlatformImage> = NSCache(),
cache: ImageCache = ImageCache(),
instrumentation: ImageStreamerInstrumentation? = nil
)
```
Expand Down
22 changes: 3 additions & 19 deletions Example/ImageStreamerApp.swiftpm/Components/ImageGridView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -35,12 +34,6 @@ struct ImageGridView: View {
}
}
}

if isLoadingMore {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
}
}
.padding(4)
}
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 0 additions & 1 deletion Example/ImageStreamerApp.swiftpm/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ struct ContentView: View {
ContentView()
.environment(\.imageStreamer, ImageStreamer(
session: URLSession.shared,
cache: NSCache(),
instrumentation: StandardImageStreamerInstrumentation()
))
}
8 changes: 4 additions & 4 deletions Example/ImageStreamerApp.swiftpm/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -50,5 +50,5 @@ let package = Package(
path: "."
)
],
swiftLanguageModes: [.version("6")]
)
swiftLanguageVersions: [.version("6")]
)
20 changes: 12 additions & 8 deletions Example/ImageStreamerApp.swiftpm/ShowcaseApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
52 changes: 0 additions & 52 deletions GEMINI.md

This file was deleted.

4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -79,7 +79,7 @@ The entire `ImageStreamer` sits behind protocols (`ImageStreamerProtocol`, `Imag
```swift
public init(
session: ImageFetching = URLSession.shared,
cache: NSCache<ImageCacheKey, PlatformImage> = NSCache(),
cache: ImageCache = ImageCache(),
instrumentation: ImageStreamerInstrumentation? = nil
)
```
Expand Down
Loading