Project 8 · AI checkpoint — the download-progress data raceswift-6.4/ios-26
Lesson 5 / 6
Unit 28 · Trackcast

Project 8 · AI checkpoint — the download-progress data race

Note

Unit 28 · Project 8 · AI checkpoint. You asked Claude Code for a download manager that tracks per-episode progress. It produced the code below. It compiles. It works in a demo. It has a real data race that only bites under load. Your job: find it, explain it, fix it.

This is the Professional stage in miniature. You delegated a component, you got back something that looks right, and now you review it as an engineer. Read the code before the questions.

The AI-produced code

Prompt to Claude Code: "Write a DownloadManager that runs several URLSession background downloads at once and tracks each one's progress so I can show an overall percentage."

import Foundation

final class DownloadManager: NSObject, URLSessionDownloadDelegate, @unchecked Sendable {
    // Progress for each in-flight download, keyed by task id.
    private var progress: [Int: Double] = [:]

    var overallProgress: Double {
        guard !progress.isEmpty else { return 0 }
        return progress.values.reduce(0, +) / Double(progress.count)
    }

    func urlSession(_ session: URLSession,
                    downloadTask: URLSessionDownloadTask,
                    didWriteData bytesWritten: Int64,
                    totalBytesWritten: Int64,
                    totalBytesExpectedToWrite: Int64) {
        let fraction = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
        // Record this task's progress.
        progress[downloadTask.taskIdentifier] = fraction
    }

    func urlSession(_ session: URLSession,
                    downloadTask: URLSessionDownloadTask,
                    didFinishDownloadingTo location: URL) {
        progress[downloadTask.taskIdentifier] = 1.0
    }
}

Guiding questions

Work through these before reading the explanation.

  1. didWriteData fires as bytes arrive. If five episodes are downloading at once, how many different threads can be executing that method at the same time, and what queue delivers them?
  2. progress is a plain var [Int: Double]. What happens to a Swift Dictionary when two threads write different keys into it simultaneously?
  3. The class is marked @unchecked Sendable. What is that annotation promising the compiler, and is the promise actually kept here?
  4. The bug rarely shows up when you test one download by hand. Why does testing a single download hide it, and what makes it appear in production?
  5. What is the smallest change that makes every access to progress mutually exclusive without sprinkling locks through the callbacks?
Tip

The tell is @unchecked Sendable on a class with mutable state and no synchronization. That annotation means "trust me, I made this thread-safe" — so the first review question is always "where is the synchronization it is promising?" Here there is none.

Explanation

The flaw is a data race on progress. A URLSession created with a background configuration delivers its delegate callbacks on the session's delegate queue, and with several concurrent downloads didWriteData is called concurrently for different tasks. Each call does progress[taskIdentifier] = fraction — a mutating write to a shared Dictionary. Dictionary is a value type with no internal synchronization; two concurrent writes can reallocate its storage at the same moment and corrupt it. The symptoms are a wrong overallProgress, and under enough load, a crash inside the standard library's hashing code.

The @unchecked Sendable is the smoking gun. Sendable is the compiler's promise that a type is safe to share across concurrency domains. The @unchecked variant switches that check off and asks you to guarantee it — usually because you protect the state with a lock the compiler can't see. Here nothing protects progress, so the annotation is a lie that silences the exact diagnostic that would have caught the bug. That is why it survives review by anyone who reads the annotation as reassurance rather than a claim to verify.

It hides in a demo because one download produces serial callbacks — there is no second thread to race with. It appears in production because real users download several episodes over Wi-Fi at once, which is precisely when the callbacks overlap.

The fix: isolate the shared state behind an actor. An actor serializes every access to its stored properties, so concurrent update calls can't tear each other, and Sendable is guaranteed by the language instead of asserted by you:

actor ProgressTracker {
    private var progress: [Int: Double] = [:]

    func update(_ id: Int, _ value: Double) {
        progress[id] = value
    }

    func overall() -> Double {
        guard !progress.isEmpty else { return 0 }
        return progress.values.reduce(0, +) / Double(progress.count)
    }
}

The delegate then holds a ProgressTracker and updates it from a Task:

func urlSession(_ session: URLSession,
                downloadTask: URLSessionDownloadTask,
                didWriteData bytesWritten: Int64,
                totalBytesWritten: Int64,
                totalBytesExpectedToWrite: Int64) {
    let fraction = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
    let id = downloadTask.taskIdentifier
    Task { await tracker.update(id, fraction) }
}

Now the callback captures only Sendable values (Int, Double) into the Task, hands them to the actor, and the actor is the single serialization point. Drop the @unchecked Sendable on the delegate class and let the compiler check the rest.

Your turn

Implement the actor that fixes the race. The tests fire 100 concurrent updates from a task group and assert the average is exact — which only holds if no write is lost.

Fix the download progress tracker
Edit the code on the right, then run the hidden tests.

Knowledge check

Q: What does @unchecked Sendable actually promise, and why was it wrong here? It promises the author has made the type safe to share across concurrency domains by some means the compiler can't verify — typically a lock. Here there was no synchronization at all, so the annotation silenced the correct data-race diagnostic without delivering the safety it claimed.

Q: Why does an actor fix this where marking the class Sendable did not? An actor enforces mutual exclusion on its own state as a language guarantee: only one task runs its isolated code at a time, so concurrent update calls are serialized and no write is lost. Sendable only classifies whether a type may cross a boundary; it does not add the synchronization that classification requires.

Solution.swiftSwift Testing
ConsoleReady · runs in a sandboxed Swift container
Press Run to compile against the hidden test suite.