All skills

Swift language patterns and best practices including concurrency, performance, and modern idioms. Use for Swift language-level code review or architecture guidance.

  • 14 files
  • 135.6 KB
  • Updated 2 months ago
  • GitHub

Use this Skill: https://skilld.dev/gh/rshankras/claude-code-apple-skills/swift

This session only. Nothing lands on disk.

concurrency-patternscontinuations-bridging.md

≈3.1k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Continuations and Bridging

Patterns for wrapping legacy callback-based, delegate-based, and notification-based APIs into async/await. AsyncSequence adoption rules sourced from Apple's WWDC21 "Meet AsyncSequence" (10058).

What to Convert (WWDC21 10058)

Apple's candidate rule: "pretty much anything that does not need a response back and is just informing of a new value that occurs can be a prime candidate for making an async sequence" — multi-shot callbacks, closures, and many delegates. One-shot callbacks become continuations instead (see below).

Check the built-ins before writing an adapter — these ship as async sequences (iOS 15+/macos 12+):

API What it yields
URL.lines / URL.bytes Lines/bytes from a file or the network, streamed as received — process elements before the download completes
FileHandle.bytes (+ .lines) Bytes/lines from a file handle, incl. FileHandle.standardInput
URLSession.bytes(from:) (bytes, response) — validate statusCode == 200 before iterating
NotificationCenter.notifications(named:) Notifications; combine with .first { … } to await a single matching one

Standard operators have async counterparts: map, filter, reduce, dropFirst, first(where:), prefix. Termination rules: nil from the iterator and thrown errors are both terminal — "after an error happens, they'll return nil for any subsequent calls to next." To make an indefinite iteration externally cancellable, wrap the for await loop in a stored Task and cancel it.

withCheckedContinuation

Wraps a single-callback API into an async function:

func currentLocation() async -> CLLocation {
    await withCheckedContinuation { continuation in
        locationManager.requestLocation { location in
            continuation.resume(returning: location)
        }
    }
}

Throwing Variant

func fetchImage(named name: String) async throws -> UIImage {
    try await withCheckedThrowingContinuation { continuation in
        imageLoader.load(name: name) { result in
            switch result {
            case .success(let image):
                continuation.resume(returning: image)
            case .failure(let error):
                continuation.resume(throwing: error)
            }
        }
    }
}

The "Exactly Once" Rule

A continuation must be resumed exactly once. Resuming zero times leaks the task forever. Resuming twice crashes.

// ❌ Bug — continuation never resumed on timeout
func fetchWithTimeout() async throws -> Data {
    try await withCheckedThrowingContinuation { continuation in
        apiClient.fetch { data in
            continuation.resume(returning: data)
        }
        // If timeout fires and callback never fires → leaked forever
    }
}

// ❌ Bug — continuation resumed twice
func fetchData() async throws -> Data {
    try await withCheckedThrowingContinuation { continuation in
        apiClient.fetch { result in
            switch result {
            case .success(let data):
                continuation.resume(returning: data)
            case .failure(let error):
                continuation.resume(throwing: error)
            }
        }
        // What if the callback fires twice? Second resume → crash
    }
}

Fix: Guard with a flag or use the callback structure carefully:

// ✅ Ensure exactly one resume
func fetchData() async throws -> Data {
    try await withCheckedThrowingContinuation { continuation in
        var hasResumed = false

        apiClient.fetch { result in
            guard !hasResumed else { return }
            hasResumed = true

            switch result {
            case .success(let data):
                continuation.resume(returning: data)
            case .failure(let error):
                continuation.resume(throwing: error)
            }
        }
    }
}

Checked vs Unsafe Continuations

Type Debug Behavior Release Behavior Use When
withCheckedContinuation Traps on misuse (zero or double resume) Traps on misuse Default choice, always start here
withUnsafeContinuation No checking No checking Performance-critical hot paths only

Diagnosing a leak in the field (WWDC22 110350): a never-resumed continuation prints a console warning when the continuation is destroyed ("the continuation leaked"), and the Swift Concurrency Instrument shows the task stuck indefinitely in the continuation state — see concurrency-internals.md.

Swift 6.4 adds a noncopyable Continuation type that "checks at compile time that you only resume it once, making it even safer than a CheckedContinuation but just as efficient as an UnsafeContinuation" (WWDC26 262) — prefer it once your toolchain allows.

Bridging Delegate APIs

Many Apple APIs use delegates (CLLocationManager, ASAuthorizationController, etc.). Bridge them with a continuation-holding helper:

class LocationFetcher: NSObject, CLLocationManagerDelegate {
    private var continuation: CheckedContinuation<CLLocation, Error>?
    private let manager = CLLocationManager()

    override init() {
        super.init()
        manager.delegate = self
    }

    func requestLocation() async throws -> CLLocation {
        try await withCheckedThrowingContinuation { continuation in
            self.continuation = continuation
            manager.requestLocation()
        }
    }

    // MARK: - CLLocationManagerDelegate

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        continuation?.resume(returning: locations.last!)
        continuation = nil
    }

    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        continuation?.resume(throwing: error)
        continuation = nil
    }
}

// Usage:
let fetcher = LocationFetcher()
let location = try await fetcher.requestLocation()

Sign in with Apple

class AppleSignInCoordinator: NSObject, ASAuthorizationControllerDelegate {
    private var continuation: CheckedContinuation<ASAuthorization, Error>?

    func signIn() async throws -> ASAuthorization {
        let provider = ASAuthorizationAppleIDProvider()
        let request = provider.createRequest()
        request.requestedScopes = [.fullName, .email]

        let controller = ASAuthorizationController(authorizationRequests: [request])
        controller.delegate = self

        return try await withCheckedThrowingContinuation { continuation in
            self.continuation = continuation
            controller.performRequests()
        }
    }

    func authorizationController(controller: ASAuthorizationController,
                                  didCompleteWithAuthorization authorization: ASAuthorization) {
        continuation?.resume(returning: authorization)
        continuation = nil
    }

    func authorizationController(controller: ASAuthorizationController,
                                  didCompleteWithError error: Error) {
        continuation?.resume(throwing: error)
        continuation = nil
    }
}

AsyncStream — Bridging Multi-Value Sources

For APIs that produce multiple values over time (delegates, NotificationCenter, KVO), use AsyncStream:

NotificationCenter

extension NotificationCenter {
    func notifications(named name: Notification.Name) -> AsyncStream<Notification> {
        AsyncStream { continuation in
            let observer = addObserver(forName: name, object: nil, queue: nil) { notification in
                continuation.yield(notification)
            }
            continuation.onTermination = { @Sendable _ in
                NotificationCenter.default.removeObserver(observer)
            }
        }
    }
}

// Usage:
for await notification in NotificationCenter.default.notifications(named: .NSManagedObjectContextDidSave) {
    await handleSave(notification)
}

CLLocationManager Continuous Updates

class LocationStream: NSObject, CLLocationManagerDelegate {
    private var continuation: AsyncStream<CLLocation>.Continuation?
    private let manager = CLLocationManager()

    func locations() -> AsyncStream<CLLocation> {
        AsyncStream { continuation in
            self.continuation = continuation
            continuation.onTermination = { @Sendable [weak self] _ in
                self?.manager.stopUpdatingLocation()
            }
            manager.delegate = self
            manager.startUpdatingLocation()
        }
    }

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        for location in locations {
            continuation?.yield(location)
        }
    }
}

// Usage:
for await location in locationStream.locations() {
    updateMap(with: location)
}

AsyncStream with Buffering

AsyncStream(bufferingPolicy: .bufferingNewest(10)) { continuation in
    // Keeps only the 10 most recent values if consumer is slow
    eventSource.onEvent = { event in
        continuation.yield(event)
    }
}

Buffering policies:

Policy Behavior
.unbounded Buffer everything (default, can grow unbounded)
.bufferingNewest(N) Keep only the N most recent values
.bufferingOldest(N) Keep only the N oldest values, drop new ones

Why AsyncStream over hand-rolled AsyncSequence conformance (WWDC21 10058): "it handles all of the things you would expect from an async sequence, like safety, iteration, and cancellation; but they also handle buffering," and it "is a suitable return type from your own APIs." Encapsulating the start/stop bookkeeping in the stream "reduces the need to replicate the same logic in every use site."

AsyncThrowingStream

For streams that can also produce errors:

func eventStream() -> AsyncThrowingStream<Event, Error> {
    AsyncThrowingStream { continuation in
        websocket.onMessage = { message in
            continuation.yield(message)
        }
        websocket.onError = { error in
            continuation.finish(throwing: error)
        }
        websocket.onClose = {
            continuation.finish()
        }
    }
}

// Usage:
do {
    for try await event in eventStream() {
        handle(event)
    }
} catch {
    handleDisconnection(error)
}

Common Mistakes

Forgetting onTermination Cleanup

// ❌ Resource leak — observer never removed
AsyncStream<Notification> { continuation in
    let observer = NotificationCenter.default.addObserver(...)
    // No cleanup when stream is cancelled
}

// ✅ Clean up on termination
AsyncStream<Notification> { continuation in
    let observer = NotificationCenter.default.addObserver(...)
    continuation.onTermination = { @Sendable _ in
        NotificationCenter.default.removeObserver(observer)
    }
}

Capturing Self Strongly in Continuation

// ❌ Retain cycle — continuation holds self, self holds continuation
class StreamProvider {
    var continuation: AsyncStream<Event>.Continuation?

    func events() -> AsyncStream<Event> {
        AsyncStream { continuation in
            self.continuation = continuation  // Strong reference cycle
        }
    }
}

// ✅ Use weak self in onTermination, nil out continuation
continuation.onTermination = { @Sendable [weak self] _ in
    self?.continuation = nil
}

Using AsyncStream for Single Values

// ❌ Overkill — AsyncStream for a one-shot result
func fetchUser() -> AsyncStream<User> {
    AsyncStream { continuation in
        api.getUser { user in
            continuation.yield(user)
            continuation.finish()
        }
    }
}

// ✅ Use withCheckedContinuation for single values
func fetchUser() async -> User {
    await withCheckedContinuation { continuation in
        api.getUser { user in
            continuation.resume(returning: user)
        }
    }
}

Checklist

  • Using withCheckedContinuation (not unsafe) unless profiling demands it
  • Continuation resumed exactly once in all code paths
  • continuation = nil after resuming to prevent double-resume
  • onTermination handler cleans up resources (observers, delegates, timers)
  • AsyncStream for multi-value sources, withCheckedContinuation for single-value
  • Appropriate buffering policy chosen for AsyncStream
  • No strong reference cycles between continuation holder and stream provider

Source: SKILL.md on GitHub

No third-party reports yet.

Signed by skilld at 96f4900. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 months ago.

Steadyupdated 2 months ago
What it can do
Reads files
last_verified
2026-07-16
review_by
2027-06-22
os_version
iOS 27 / macOS 27
All 3 allowed tools
ReadGlobGrep

README badge

README badge for rshankras/claude-code-apple-skills/swift