Project 8 · AI checkpoint — the download-progress data race
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
DownloadManagerthat runs severalURLSessionbackground 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.
didWriteDatafires 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?progressis a plainvar [Int: Double]. What happens to a SwiftDictionarywhen two threads write different keys into it simultaneously?- The class is marked
@unchecked Sendable. What is that annotation promising the compiler, and is the promise actually kept here? - 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?
- What is the smallest change that makes every access to
progressmutually exclusive without sprinkling locks through the callbacks?
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.
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.