
Ios Chaos Monkey
- 205 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
ios-chaos-monkey: A skill for development. This provides functionality for development workflows.
Key points
- ios-chaos-monkey
Ios Chaos Monkey by the numbers
- 205 all-time installs (skills.sh)
- +7 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,973 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill ios-chaos-monkeyAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 205 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I use ios-chaos-monkey for development tasks?
Use ios-chaos-monkey for development tasks
Who is it for?
Best when you're working on backend & apis and need structured help with ios-chaos-monkey.
Skip if: Teams with no backend & apis needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to use ios-chaos-monkey for development tasks, or when ios-chaos-monkey: a skill for development. this provides functionality for development workflows.
What you get
Structured output aligned to ios-chaos-monkey: ios-chaos-monkey.
Files
iOS Chaos Monkey — Crash-Hunter Best Practices
Adversarial crash-hunting guide for iOS and Swift applications. Contains 47 rules across 8 categories, prioritized by crash severity. Every rule follows TDD: dangerous code first, a failing test that proves the bug, then the fix that makes the test pass.
Clinic Architecture Contract (iOS 26 / Swift 6.2)
All guidance in this skill assumes the clinic modular MVVM-C architecture:
- Feature modules import
Domain+DesignSystemonly (neverData, never sibling features) - App target is the convergence point and owns
DependencyContainer, concrete coordinators, and Route Shell wiring Domainstays pure Swift and defines models plus repository,*Coordinating,ErrorRouting, andAppErrorcontractsDataowns SwiftData/network/sync/retry/background I/O and implements Domain protocols- Read/write flow defaults to stale-while-revalidate reads and optimistic queued writes
- ViewModels call repository protocols directly (no default use-case/interactor layer)
When to Apply
Reference these guidelines when:
- Hunting data races, deadlocks, and concurrency crashes in Swift
- Auditing memory management for retain cycles and use-after-free
- Reviewing async/await code for cancellation and continuation leaks
- Stress-testing file I/O and CoreData/SwiftData persistence layers
- Writing proof-of-crash tests before implementing fixes
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Data Races & Thread Safety | CRITICAL | race- |
| 2 | Memory Corruption & Leaks | CRITICAL | mem- |
| 3 | Deadlocks & Thread Starvation | HIGH | dead- |
| 4 | Async/Await & Structured Concurrency | HIGH | async- |
| 5 | File I/O & Persistence Corruption | MEDIUM-HIGH | io- |
| 6 | Collection & State Mutation | MEDIUM | mut- |
| 7 | Resource Exhaustion | MEDIUM | exhaust- |
| 8 | Objective-C Interop Traps | LOW-MEDIUM | objc- |
Quick Reference
1. Data Races & Thread Safety (CRITICAL)
- `race-dictionary-concurrent-write` - Concurrent Dictionary mutation crashes with EXC_BAD_ACCESS
- `race-array-concurrent-append` - Concurrent Array append corrupts internal buffer
- `race-property-access` - Unsynchronized property read-write across threads
- `race-lazy-initialization` - Lazy property double-initialization under concurrency
- `race-singleton-initialization` - Non-atomic singleton exposes partially constructed state
- `race-bool-flag` - Non-atomic Bool flag creates check-then-act race
- `race-closure-capture-mutation` - Closure captures mutable reference across threads
- `race-delegate-nilification` - Delegate set to nil during active callback
2. Memory Corruption & Leaks (CRITICAL)
- `mem-closure-retain-cycle` - Strong self capture in escaping closures creates retain cycle
- `mem-timer-retain-cycle` - Timer retains target creating undiscoverable retain cycle
- `mem-delegate-strong-reference` - Strong delegate reference prevents deallocation
- `mem-unowned-crash` - Unowned reference crashes after owner deallocation
- `mem-notification-observer-leak` - NotificationCenter observer retains closure after removal needed
- `mem-combine-sink-retain` - Combine sink retains self without cancellable storage
- `mem-async-task-self-capture` - Task captures self extending lifetime beyond expected scope
3. Deadlocks & Thread Starvation (HIGH)
- `dead-sync-on-main` - DispatchQueue.main.sync from main thread deadlocks instantly
- `dead-recursive-lock` - Recursive lock acquisition on same serial queue
- `dead-actor-reentrancy` - Actor reentrancy produces unexpected interleaving
- `dead-semaphore-in-async` - Semaphore.wait() inside async context deadlocks thread pool
- `dead-queue-hierarchy` - Dispatch queue target hierarchy inversion deadlocks
- `dead-mainactor-blocking` - Blocking MainActor with synchronous heavy work
4. Async/Await & Structured Concurrency (HIGH)
- `async-missing-cancellation` - Missing Task.isCancelled check wastes resources after navigation
- `async-detached-task-leak` - Detached task without cancellation handle leaks work
- `async-task-group-error` - TaskGroup silently drops child task errors
- `async-continuation-leak` - CheckedContinuation never resumed leaks awaiting task
- `async-actor-hop-starvation` - Excessive MainActor hops in hot loop starve UI updates
- `async-unsafe-sendable` - @unchecked Sendable hides data race from compiler
5. File I/O & Persistence Corruption (MEDIUM-HIGH)
- `io-concurrent-file-write` - Concurrent file writes corrupt data without coordination
- `io-coredata-cross-thread` - CoreData NSManagedObject accessed from wrong thread
- `io-swiftdata-background` - SwiftData model accessed from wrong ModelContext
- `io-plist-concurrent-mutation` - UserDefaults concurrent read-write produces stale values
- `io-filemanager-race` - FileManager existence check then use is a TOCTOU race
- `io-keychain-thread-safety` - Keychain access from multiple threads returns unexpected errors
6. Collection & State Mutation (MEDIUM)
- `mut-enumerate-and-mutate` - Collection mutation during enumeration crashes at runtime
- `mut-kvo-dealloc-crash` - KVO observer not removed before deallocation crashes
- `mut-index-out-of-bounds` - Array index access without bounds check crashes
- `mut-force-unwrap` - Force unwrapping optional in production crashes on nil
- `mut-enum-future-cases` - Non-exhaustive switch crashes on unknown enum case
7. Resource Exhaustion (MEDIUM)
- `exhaust-unbounded-task-spawn` - Unbounded task spawning in loop exhausts memory
- `exhaust-thread-explosion` - GCD creates unbounded threads under concurrent load
- `exhaust-urlsession-leak` - URLSession not invalidated leaks delegate and connections
- `exhaust-file-descriptor-leak` - File handle not closed leaks file descriptors
- `exhaust-memory-warning-ignored` - Low memory warning ignored triggers Jetsam kill
8. Objective-C Interop Traps (LOW-MEDIUM)
- `objc-unrecognized-selector` - Missing @objc annotation crashes with unrecognized selector
- `objc-nsnull-in-json` - NSNull in decoded JSON collection crashes on access
- `objc-bridge-type-mismatch` - Swift/ObjC bridge type mismatch crashes at runtime
- `objc-dynamic-dispatch` - Missing dynamic keyword breaks method swizzling
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
{Rule Title}
{1-3 sentences explaining WHY this matters. Focus on crash/corruption implications.}
Incorrect ({description of the crash/bug}):
{Dangerous code that will crash under specific conditions}
{// Comment on the KEY line explaining the cost}Proof Test (exposes the crash):
{XCTest that FAILS with the incorrect code — proves the bug exists}Correct ({description of the fix}):
{Safe code that makes the proof test pass}
{// Comment on the KEY line explaining the fix}{
"version": "1.0.4",
"organization": "iOS Chaos Monkey",
"technology": "iOS 26 / Swift 6.2",
"date": "February 2026",
"abstract": "Adversarial crash-hunting guide for iOS and Swift applications, designed for AI agents and LLMs. Contains 47 rules across 8 categories, prioritized by crash severity from critical (data races, memory corruption) to incremental (Objective-C interop traps). Every rule follows TDD: dangerous code, a failing test that proves the crash, and the fix that makes the test pass. Complements ios-testing, swift-optimise, and other ios-*/swift-* skills. Aligned with the iOS 26 / Swift 6.2 clinic modular MVVM-C architecture.",
"references": [
"https://developer.apple.com/documentation/swift/adoptingswift6",
"https://www.swift.org/migration/documentation/swift-6-concurrency-migration-guide/commonproblems/",
"https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/CoreData/Concurrency.html",
"https://www.avanderlee.com/swift/thread-sanitizer-data-races/",
"https://www.swiftbysundell.com/articles/avoiding-race-conditions-in-swift/",
"https://www.hackingwithswift.com/swift/6.0/concurrency",
"https://developer.apple.com/documentation/swift/actor",
"https://www.avanderlee.com/concurrency/approachable-concurrency-in-swift-6-2-a-clear-guide/"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Data Races & Thread Safety (race)
Impact: CRITICAL Description: Concurrent access to shared mutable state is the #1 cause of intermittent production crashes. A single unprotected dictionary write from two threads corrupts memory and triggers EXC_BAD_ACCESS that reproduces on 1 in 1000 launches.
2. Memory Corruption & Leaks (mem)
Impact: CRITICAL Description: Retain cycles, use-after-deallocation, and dangling references cause crashes that manifest far from the source. Memory leaks compound silently until iOS terminates the app under pressure with no stack trace.
3. Deadlocks & Thread Starvation (dead)
Impact: HIGH Description: Synchronous dispatch on blocked queues, recursive locking, and main thread starvation freeze the app with zero user feedback. The watchdog kills the process after 10 seconds of main thread blockage.
4. Async/Await & Structured Concurrency (async)
Impact: HIGH Description: Ignored task cancellation, leaked continuations, and actor reentrancy create time-bombs that crash under real-world load. These bugs pass unit tests but detonate in production when timing shifts.
5. File I/O & Persistence Corruption (io)
Impact: MEDIUM-HIGH Description: CoreData thread violations, concurrent file writes, and SwiftData cross-context access corrupt user data silently. The crash occurs on next launch when the app reads the corrupted store.
6. Collection & State Mutation (mut)
Impact: MEDIUM Description: Mutating collections during enumeration, KVO observer leaks, and force-unwrapping optionals in production create deterministic crashes that bypass code review because the happy path works.
7. Resource Exhaustion (exhaust)
Impact: MEDIUM Description: Unbounded task spawning, GCD thread explosion, and file descriptor leaks starve the system until iOS terminates the process. These crashes appear only under sustained load, never in development.
8. Objective-C Interop Traps (objc)
Impact: LOW-MEDIUM Description: Unrecognized selector crashes, NSNull in decoded JSON, and bridge type mismatches at the Swift/ObjC boundary bypass Swift's type safety and crash at runtime with no compile-time warning.
Excessive MainActor Hops in Hot Loop Starve UI Updates
Awaiting a @MainActor method inside a tight loop causes one context switch per iteration. Each hop enqueues work on the main run loop, starving UIKit event handling, scroll rendering, and animation callbacks. For 1000 items, the accumulated overhead of 1000 actor hops makes the UI unresponsive for hundreds of milliseconds to seconds.
Incorrect (one MainActor hop per item starves the run loop):
import Foundation
@MainActor
class DataSynchronizer {
var items: [String] = []
var progress: Double = 0.0
func syncFromServer() async throws {
let fetched = try await fetchItems()
for (index, item) in fetched.enumerated() {
await updateUI(item: item, index: index, total: fetched.count)
}
}
func updateUI(item: String, index: Int, total: Int) {
items.append(item)
progress = Double(index + 1) / Double(total) // hop per item
}
nonisolated func fetchItems() async throws -> [String] {
(0..<1000).map { "item-\($0)" }
}
}Proof Test (exposes run loop starvation during batch sync):
import XCTest
@testable import MyApp
final class DataSynchronizerHopTests: XCTestCase {
@MainActor
func testSyncDoesNotStarveRunLoop() async throws {
let synchronizer = DataSynchronizer()
var runLoopTicks = 0
// Count run loop ticks during sync — should stay responsive
let timer = Timer.scheduledTimer(withTimeInterval: 0.016, repeats: true) { _ in
runLoopTicks += 1
}
let start = CFAbsoluteTimeGetCurrent()
try await synchronizer.syncFromServer()
let elapsed = CFAbsoluteTimeGetCurrent() - start
timer.invalidate()
// With incorrect code, timer barely fires — run loop starved
XCTAssertGreaterThan(runLoopTicks, 5, "Run loop starved: only \(runLoopTicks) ticks")
XCTAssertLessThan(elapsed, 0.5, "Sync took \(elapsed)s — too slow")
}
}Correct (batch items, single MainActor hop to update UI):
import Foundation
@MainActor
class DataSynchronizer {
var items: [String] = []
var progress: Double = 0.0
func syncFromServer() async throws {
let fetched = try await fetchItems()
await updateUIBatch(items: fetched) // single hop for all items
}
func updateUIBatch(items newItems: [String]) {
items.append(contentsOf: newItems) // one update, one hop
progress = 1.0
}
nonisolated func fetchItems() async throws -> [String] {
(0..<1000).map { "item-\($0)" }
}
}CheckedContinuation Never Resumed Leaks Awaiting Task
When withCheckedContinuation wraps a callback-based API, every execution path must call resume exactly once. If the callback has an early return, error branch, or timeout that skips resume(), the awaiting task is suspended forever. The task, its captured state, and everything it retains leak permanently. CheckedContinuation logs a runtime warning but the task still hangs.
Incorrect (error path skips resume, task hangs forever):
import CoreLocation
class LocationManager: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
private var continuation: CheckedContinuation<CLLocation, Error>?
func requestLocation() async throws -> CLLocation {
try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation
manager.delegate = self
manager.requestLocation()
}
}
func locationManager(_ mgr: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
continuation?.resume(returning: locations[0])
continuation = nil
}
func locationManager(_ mgr: CLLocationManager, didFailWithError error: Error) {
// BUG: if error is kCLErrorDomain, resume is never called
guard (error as? CLError)?.code != .denied else { return }
continuation?.resume(throwing: error)
continuation = nil
}
}Proof Test (exposes the leaked continuation on authorization denial):
import XCTest
import CoreLocation
@testable import MyApp
final class LocationManagerContinuationTests: XCTestCase {
func testLocationDeniedResumesWithError() async {
let locationManager = LocationManager()
let expectation = expectation(description: "continuation resumes")
Task {
do {
_ = try await locationManager.requestLocation()
XCTFail("Should have thrown on denial")
} catch {
expectation.fulfill() // never reached — continuation leaked
}
}
// Simulate authorization denial
let error = CLError(.denied)
locationManager.locationManager(CLLocationManager(), didFailWithError: error)
await fulfillment(of: [expectation], timeout: 3)
}
}Correct (every exit path resumes the continuation exactly once):
import CoreLocation
class LocationManager: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
private var continuation: CheckedContinuation<CLLocation, Error>?
func requestLocation() async throws -> CLLocation {
try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation
manager.delegate = self
manager.requestLocation()
}
}
func locationManager(_ mgr: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
continuation?.resume(returning: locations[0])
continuation = nil
}
func locationManager(_ mgr: CLLocationManager, didFailWithError error: Error) {
continuation?.resume(throwing: error) // resumes on ALL errors
continuation = nil
}
}Detached Task Without Cancellation Handle Leaks Work
Task.detached { ... } returns a Task handle that, when discarded, leaves the work running with no way to cancel it. Each navigation cycle spawns a new detached task while old ones continue in the background. Under memory pressure, the accumulated work causes the app to exceed its memory budget and be terminated by the system.
Incorrect (discards Task handle, orphaned syncs accumulate):
import Foundation
class SyncManager {
func startPeriodicSync() {
Task.detached { // handle discarded — no way to cancel
while true {
await self.performSync()
try? await Task.sleep(for: .seconds(30))
}
}
}
func performSync() async {
// Simulates fetching and processing server data
try? await Task.sleep(for: .seconds(2))
print("Sync completed at \(Date())")
}
}Proof Test (exposes orphaned task accumulation across multiple starts):
import XCTest
@testable import MyApp
final class SyncManagerLeakTests: XCTestCase {
func testStopCancelsPeriodicSync() async throws {
var syncCount = 0
let manager = SyncManager()
// Simulate 3 navigation cycles, each starting a new sync
for _ in 0..<3 {
manager.startPeriodicSync()
}
try await Task.sleep(for: .seconds(5))
// With incorrect code, 3 syncs are running concurrently
// There is no way to stop them — they accumulate forever
XCTFail("No mechanism to cancel orphaned tasks")
}
}Correct (stores Task handle, cancels in stop/deinit):
import Foundation
class SyncManager {
private var syncTask: Task<Void, Never>?
func startPeriodicSync() {
syncTask?.cancel() // cancel previous sync before starting new one
syncTask = Task.detached { [weak self] in
while !Task.isCancelled {
await self?.performSync()
try? await Task.sleep(for: .seconds(30))
}
}
}
func stopSync() {
syncTask?.cancel()
syncTask = nil
}
func performSync() async {
try? await Task.sleep(for: .seconds(2))
print("Sync completed at \(Date())")
}
deinit { syncTask?.cancel() }
}Missing Task.isCancelled Check Wastes Resources After Navigation
A long-running task loop that never checks Task.isCancelled continues to fetch and process data long after the user has navigated away. The work consumes CPU, memory, and network bandwidth for results that will be discarded, draining battery and overwriting state that the new screen depends on.
Incorrect (download loop ignores cancellation, runs until complete):
import Foundation
class ImageDownloader {
func downloadBatch(urls: [URL]) async throws -> [Data] {
var results: [Data] = []
for url in urls {
// No cancellation check — continues after task is cancelled
let (data, _) = try await URLSession.shared.data(from: url)
results.append(data)
}
return results
}
}Proof Test (exposes that work continues after cancellation):
import XCTest
@testable import MyApp
final class ImageDownloaderCancellationTests: XCTestCase {
func testDownloadStopsAfterCancellation() async throws {
let downloader = ImageDownloader()
let urls = (0..<100).map {
URL(string: "https://httpbin.org/bytes/1024?id=\($0)")!
}
var downloadedCount = 0
let task = Task {
let results = try await downloader.downloadBatch(urls: urls)
downloadedCount = results.count
}
try await Task.sleep(for: .milliseconds(200))
task.cancel()
try await Task.sleep(for: .seconds(3))
// With incorrect code, all 100 downloads complete despite cancellation
XCTAssertLessThan(downloadedCount, urls.count, "Work continued after cancel")
}
}Correct (checkCancellation at each iteration stops work promptly):
import Foundation
class ImageDownloader {
func downloadBatch(urls: [URL]) async throws -> [Data] {
var results: [Data] = []
for url in urls {
try Task.checkCancellation() // throws if task was cancelled
let (data, _) = try await URLSession.shared.data(from: url)
results.append(data)
}
return results
}
}TaskGroup Silently Drops Child Task Errors
When a ThrowingTaskGroup child throws an error, the error is only surfaced if the parent iterates through all results with for try await. If the parent only calls group.addTask without iterating, or breaks out of iteration early, thrown errors are silently discarded. The caller believes all operations succeeded while some failed, leading to data inconsistency.
Incorrect (never iterates group results, child errors are swallowed):
import Foundation
class BatchUploader {
func uploadAll(items: [Data], to url: URL) async throws -> Int {
var successCount = 0
try await withThrowingTaskGroup(of: Bool.self) { group in
for item in items {
group.addTask {
try await self.upload(item, to: url) // error thrown here
return true
}
}
// Never iterates — child errors silently dropped
successCount = items.count // assumes all succeeded
}
return successCount
}
private func upload(_ data: Data, to url: URL) async throws -> Void {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.httpBody = data
let (_, response) = try await URLSession.shared.data(for: request)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw URLError(.badServerResponse)
}
}
}Proof Test (exposes that errors are invisible to the caller):
import XCTest
@testable import MyApp
final class BatchUploaderErrorTests: XCTestCase {
func testFailedUploadsAreReportedNotSwallowed() async throws {
let uploader = BatchUploader()
let items = (0..<10).map { Data("item-\($0)".utf8) }
let badURL = URL(string: "https://httpbin.org/status/500")!
let count = try await uploader.uploadAll(items: items, to: badURL)
// With incorrect code, count is 10 even though all uploads failed
XCTAssertEqual(count, 0, "Reported \(count) successes but all should fail")
}
}Correct (iterates all child results, counts only actual successes):
import Foundation
class BatchUploader {
func uploadAll(items: [Data], to url: URL) async throws -> Int {
var successCount = 0
try await withThrowingTaskGroup(of: Bool.self) { group in
for item in items {
group.addTask {
try await self.upload(item, to: url)
return true
}
}
for try await success in group { // surfaces every child error
if success { successCount += 1 }
}
}
return successCount
}
private func upload(_ data: Data, to url: URL) async throws -> Void {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.httpBody = data
let (_, response) = try await URLSession.shared.data(for: request)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw URLError(.badServerResponse)
}
}
}@unchecked Sendable Hides Data Race from Compiler
Marking a class as @unchecked Sendable to silence compiler warnings does not make it thread-safe. It tells the compiler to skip concurrency checks, hiding real data races. The class can be shared freely across isolation boundaries, and concurrent access to its mutable state crashes in production with EXC_BAD_ACCESS or produces silently corrupted values.
Incorrect (unchecked Sendable silences warnings but allows data races):
import Foundation
final class SessionStore: @unchecked Sendable {
var authToken: String = ""
var userId: String = ""
var isLoggedIn: Bool = false
func login(token: String, userId: String) {
self.authToken = token // unsynchronized write
self.userId = userId // another thread can read mid-update
self.isLoggedIn = true
}
func logout() {
isLoggedIn = false
authToken = ""
userId = ""
}
func currentSession() -> (token: String, userId: String, active: Bool) {
(authToken, userId, isLoggedIn) // torn read across 3 properties
}
}Proof Test (exposes data race with concurrent login/read under TSan):
import XCTest
@testable import MyApp
final class SessionStoreSendableTests: XCTestCase {
func testConcurrentAccessDoesNotCorruptState() async {
let store = SessionStore()
var inconsistencies = 0
await withTaskGroup(of: Void.self) { group in
// Writer: alternates login/logout
group.addTask {
for i in 0..<500 {
if i % 2 == 0 {
store.login(token: "tok-\(i)", userId: "usr-\(i)")
} else {
store.logout()
}
}
}
// Reader: checks state consistency
group.addTask {
for _ in 0..<500 {
let session = store.currentSession()
if session.active && session.token.isEmpty {
inconsistencies += 1 // logged in but no token
}
}
}
}
// TSan flags this; without TSan, inconsistencies appear ~5-10% of runs
XCTAssertEqual(inconsistencies, 0, "Detected \(inconsistencies) torn reads")
}
}Correct (actor provides real thread safety instead of suppressing warnings):
import Foundation
actor SessionStore {
var authToken: String = ""
var userId: String = ""
var isLoggedIn: Bool = false
func login(token: String, userId: String) {
self.authToken = token // actor serializes all access
self.userId = userId
self.isLoggedIn = true
}
func logout() {
isLoggedIn = false
authToken = ""
userId = ""
}
func currentSession() -> (token: String, userId: String, active: Bool) {
(authToken, userId, isLoggedIn) // always consistent
}
}Actor Reentrancy Produces Unexpected Interleaving
Actor methods that contain await release the actor's isolation at the suspension point. Other callers can interleave and mutate state between the suspension and resumption. Code that reads state before the await and uses it after operates on stale data, leading to invariant violations and data corruption.
Incorrect (balance read before await is stale after resumption):
actor BankAccount {
var balance: Double = 1000.0
func transfer(amount: Double, to other: BankAccount) async -> Bool {
guard balance >= amount else { return false } // stale check
// --- actor releases isolation here ---
let fee = await calculateFee(for: amount)
// --- another transfer may have drained balance ---
balance -= (amount + fee) // can go negative
await other.deposit(amount)
return true // claims success even on stale balance
}
func deposit(_ amount: Double) {
balance += amount
}
private func calculateFee(for amount: Double) async -> Double {
try? await Task.sleep(for: .milliseconds(10))
return amount * 0.01
}
}Proof Test (exposes negative balance and excess transfers from interleaving):
import XCTest
@testable import MyApp
final class BankAccountReentrancyTests: XCTestCase {
func testConcurrentTransfersNeverProduceNegativeBalance() async {
let account = BankAccount()
let target = BankAccount()
var successCount = 0
let lock = NSLock()
await withTaskGroup(of: Void.self) { group in
for _ in 0..<20 {
group.addTask {
let ok = await account.transfer(amount: 100, to: target)
if ok {
lock.lock()
successCount += 1
lock.unlock()
}
}
}
}
let finalBalance = await account.balance
// 20 transfers of 100 from 1000 balance — at most 10 should succeed
XCTAssertGreaterThanOrEqual(finalBalance, 0, "Balance went negative: \(finalBalance)")
XCTAssertLessThanOrEqual(successCount, 10, "Too many transfers succeeded: \(successCount)")
}
}Correct (re-check state after await, return success to caller):
actor BankAccount {
var balance: Double = 1000.0
func transfer(amount: Double, to other: BankAccount) async -> Bool {
guard balance >= amount else { return false }
let fee = await calculateFee(for: amount)
let total = amount + fee
guard balance >= total else { return false } // re-check after await
balance -= total
await other.deposit(amount)
return true // caller knows the transfer succeeded
}
func deposit(_ amount: Double) {
balance += amount
}
private func calculateFee(for amount: Double) async -> Double {
try? await Task.sleep(for: .milliseconds(10))
return amount * 0.01
}
}Blocking MainActor with Synchronous Heavy Work
Performing heavy computation or synchronous I/O on @MainActor blocks the entire UI run loop. At 60 FPS the frame budget is 16ms. Any synchronous work exceeding that causes dropped frames; work exceeding 10 seconds triggers the iOS watchdog, which kills the process with no crash report in standard tooling.
Incorrect (heavy computation blocks the main thread for seconds):
import Foundation
@MainActor
class ReportGenerator {
var reportText: String = ""
func generateReport(from entries: [String]) {
var result = ""
for entry in entries {
// Simulates heavy per-row processing — 100k entries = ~3-5 seconds
result += processEntry(entry)
}
reportText = result // UI frozen until this completes
}
private func processEntry(_ entry: String) -> String {
(0..<1000).reduce("") { acc, _ in acc + entry.hash.description }
}
}Proof Test (exposes main thread blockage exceeding frame budget):
import XCTest
@testable import MyApp
final class ReportGeneratorBlockingTests: XCTestCase {
@MainActor
func testGenerateReportDoesNotBlockMainThread() async {
let generator = ReportGenerator()
let entries = (0..<10_000).map { "entry-\($0)" }
let start = CFAbsoluteTimeGetCurrent()
generator.generateReport(from: entries)
let elapsed = CFAbsoluteTimeGetCurrent() - start
// Main thread blocked for the entire duration — UI completely frozen
XCTAssertLessThan(elapsed, 0.016, "Main thread blocked for \(elapsed)s")
}
}Correct (offload heavy work to detached task, update UI on completion):
import Foundation
@MainActor
class ReportGenerator {
var reportText: String = ""
func generateReport(from entries: [String]) async {
let result = await Task.detached(priority: .userInitiated) {
var text = ""
for entry in entries {
text += self.processEntry(entry) // runs off main thread
}
return text
}.value
reportText = result // back on MainActor for UI update
}
nonisolated private func processEntry(_ entry: String) -> String {
(0..<1000).reduce("") { acc, _ in acc + entry.hash.description }
}
}Dispatch Queue Target Hierarchy Inversion Deadlocks
When queue A targets queue B (A drains through B), synchronously dispatching from B back to A creates a cycle. Queue B holds its lock while waiting for A, but A needs to drain through B, which is locked. This is an intermittent deadlock because it only triggers when both queues are active simultaneously.
Incorrect (sync dispatch from write queue to read queue creates cycle):
import Foundation
class LayeredCache {
private let readQueue = DispatchQueue(label: "com.app.cache.read")
private let writeQueue: DispatchQueue
private var store: [String: Data] = [:]
init() {
writeQueue = DispatchQueue(
label: "com.app.cache.write",
target: readQueue // writeQueue drains through readQueue
)
}
func read(_ key: String) -> Data? {
readQueue.sync { store[key] }
}
func write(_ data: Data, forKey key: String) {
writeQueue.sync {
store[key] = data
_ = read(key) // deadlock: readQueue.sync from writeQueue's context
}
}
}Proof Test (exposes the hierarchy inversion deadlock):
import XCTest
@testable import MyApp
final class LayeredCacheHierarchyTests: XCTestCase {
func testWriteThenReadDoesNotDeadlock() {
let cache = LayeredCache()
let expectation = expectation(description: "write-read completes")
DispatchQueue.global().async {
cache.write(Data("test".utf8), forKey: "k1")
expectation.fulfill() // never reached — hierarchy inversion
}
waitForExpectations(timeout: 3) // fails — deadlocked
}
}Correct (strict hierarchy — never sync-dispatch upward, use internal unsafe read):
import Foundation
class LayeredCache {
private let readQueue = DispatchQueue(label: "com.app.cache.read")
private let writeQueue: DispatchQueue
private var store: [String: Data] = [:]
init() {
writeQueue = DispatchQueue(
label: "com.app.cache.write",
target: readQueue
)
}
func read(_ key: String) -> Data? {
readQueue.sync { store[key] }
}
func write(_ data: Data, forKey key: String) {
writeQueue.sync {
store[key] = data
_ = store[key] // direct access — already on target queue
}
}
}Recursive Lock Acquisition on Same Serial Queue
When two methods both dispatch synchronously to the same serial queue, calling one from within the other creates a deadlock. The outer sync block holds the queue, and the inner sync block waits for the queue to become available, which never happens because the outer block is still executing.
Incorrect (deadlocks when read() is called inside write()):
import Foundation
class CacheCoordinator {
private let queue = DispatchQueue(label: "com.app.cache")
private var store: [String: Data] = [:]
func write(_ data: Data, forKey key: String) {
queue.sync {
store[key] = data
let exists = read(key) // deadlock: queue.sync inside queue.sync
print("Written, exists: \(exists != nil)")
}
}
func read(_ key: String) -> Data? {
queue.sync { // blocks forever — queue already held by write()
store[key]
}
}
}Proof Test (exposes the deadlock — nested call never completes):
import XCTest
@testable import MyApp
final class CacheCoordinatorDeadlockTests: XCTestCase {
func testWriteThenReadDoesNotDeadlock() {
let cache = CacheCoordinator()
let expectation = expectation(description: "write completes")
DispatchQueue.global().async {
cache.write(Data("value".utf8), forKey: "key1")
expectation.fulfill() // never reached with incorrect code
}
waitForExpectations(timeout: 3) // fails — deadlocked
}
}Correct (private unsynchronized methods avoid recursive queue entry):
import Foundation
class CacheCoordinator {
private let queue = DispatchQueue(label: "com.app.cache")
private var store: [String: Data] = [:]
func write(_ data: Data, forKey key: String) {
queue.sync {
_write(data, forKey: key)
let exists = _read(key) // no queue.sync — already inside queue
print("Written, exists: \(exists != nil)")
}
}
func read(_ key: String) -> Data? {
queue.sync { _read(key) }
}
private func _write(_ data: Data, forKey key: String) {
store[key] = data
}
private func _read(_ key: String) -> Data? {
store[key] // unsynchronized — caller must be on queue
}
}Semaphore.wait() Inside Async Context Deadlocks Thread Pool
Using DispatchSemaphore.wait() inside an async function blocks a thread in Swift's cooperative thread pool. The pool has a small fixed size (typically equal to CPU core count). When enough concurrent tasks block on semaphores, the entire pool is consumed and no task can make progress, including the one that would signal the semaphore.
Incorrect (blocks cooperative thread pool threads with semaphore):
import Foundation
class APIClient {
func fetchSync(url: URL) -> Data? {
let semaphore = DispatchSemaphore(value: 0)
var result: Data?
URLSession.shared.dataTask(with: url) { data, _, _ in
result = data
semaphore.signal()
}.resume()
semaphore.wait() // blocks a cooperative thread
return result
}
func fetchAll(urls: [URL]) async -> [Data?] {
await withTaskGroup(of: Data?.self) { group in
for url in urls {
group.addTask {
self.fetchSync(url: url) // each task blocks a pool thread
}
}
var results: [Data?] = []
for await data in group { results.append(data) }
return results
}
}
}Proof Test (exposes thread pool exhaustion with concurrent semaphore waits):
import XCTest
@testable import MyApp
final class APIClientThreadPoolTests: XCTestCase {
func testFetchAllDoesNotDeadlockUnderLoad() async {
let client = APIClient()
let urls = (0..<20).map {
URL(string: "https://httpbin.org/delay/1?id=\($0)")!
}
let expectation = expectation(description: "fetchAll completes")
Task {
_ = await client.fetchAll(urls: urls)
expectation.fulfill() // never reached — pool exhausted
}
await fulfillment(of: [expectation], timeout: 10)
}
}Correct (CheckedContinuation bridges callback to async without blocking):
import Foundation
class APIClient {
func fetch(url: URL) async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
URLSession.shared.dataTask(with: url) { data, _, error in
if let error { continuation.resume(throwing: error) }
else if let data { continuation.resume(returning: data) }
else { continuation.resume(throwing: URLError(.badServerResponse)) }
}.resume() // non-blocking — cooperative thread is free
}
}
func fetchAll(urls: [URL]) async -> [Data?] {
await withTaskGroup(of: Data?.self) { group in
for url in urls {
group.addTask { try? await self.fetch(url: url) }
}
var results: [Data?] = []
for await data in group { results.append(data) }
return results
}
}
}DispatchQueue.main.sync from Main Thread Deadlocks Instantly
Calling DispatchQueue.main.sync while already on the main thread deadlocks immediately. The sync call blocks the current (main) thread waiting for the submitted block to execute on the main queue, but the main queue cannot execute anything because the main thread is blocked waiting. The watchdog terminates the app after 10 seconds of unresponsive main thread.
Incorrect (deadlocks instantly when called from main thread):
import Foundation
class UIFormatter {
func formatForDisplay(_ value: Double) -> String {
var result = ""
DispatchQueue.main.sync { // deadlock: main thread waits for itself
result = String(format: "%.2f", value)
}
return result
}
func updateLabel(with value: Double) {
let text = formatForDisplay(value) // called from main thread
print("Formatted: \(text)") // never reached
}
}Proof Test (exposes the deadlock — completion never fires within timeout):
import XCTest
@testable import MyApp
final class UIFormatterDeadlockTests: XCTestCase {
func testFormatDoesNotDeadlockOnMainThread() {
let formatter = UIFormatter()
let expectation = expectation(description: "format completes")
DispatchQueue.main.async {
let result = formatter.formatForDisplay(42.195)
XCTAssertEqual(result, "42.20")
expectation.fulfill() // never reached with incorrect code
}
waitForExpectations(timeout: 3) // fails — deadlocked
}
}Correct (MainActor isolation avoids the sync dispatch entirely):
import Foundation
class UIFormatter {
@MainActor
func formatForDisplay(_ value: Double) -> String {
String(format: "%.2f", value) // already on main — no dispatch needed
}
@MainActor
func updateLabel(with value: Double) {
let text = formatForDisplay(value)
print("Formatted: \(text)")
}
}File Handle Not Closed Leaks File Descriptors
Opening FileHandle for reading or writing without closing it leaks the underlying file descriptor. Each process has a limited number of descriptors (~256 on iOS). After exhaustion, every file open, socket connect, and database query fails with EMFILE — too many open files.
Incorrect (opens file handles without closing, leaking descriptors):
import Foundation
class LogRotator {
private let logDirectory: URL
init(logDirectory: URL) {
self.logDirectory = logDirectory
}
func readAllLogs() -> [String] {
var contents: [String] = []
let files = (try? FileManager.default.contentsOfDirectory(
at: logDirectory, includingPropertiesForKeys: nil)) ?? []
for file in files {
// FileHandle opened but never closed — descriptor leaks
let handle = FileHandle(forReadingAtPath: file.path)
if let data = handle?.readDataToEndOfFile() {
if let text = String(data: data, encoding: .utf8) {
contents.append(text)
}
}
// Missing: handle?.closeFile()
}
return contents
}
func appendToLog(name: String, message: String) {
let url = logDirectory.appendingPathComponent(name)
let handle = FileHandle(forWritingAtPath: url.path)
handle?.seekToEndOfFile()
handle?.write(Data(message.utf8))
// Missing: handle?.closeFile()
}
}Proof Test (exposes descriptor exhaustion after repeated opens without close):
import XCTest
final class LogRotatorDescriptorTests: XCTestCase {
func testRepeatedReadsDoNotLeakDescriptors() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("logs-\(UUID())")
try FileManager.default.createDirectory(
at: dir, withIntermediateDirectories: true)
// Create test log files
for i in 0..<300 {
let url = dir.appendingPathComponent("log-\(i).txt")
try Data("entry \(i)".utf8).write(to: url)
}
let rotator = LogRotator(logDirectory: dir)
// Read all logs multiple times — leaks descriptors each pass
for _ in 0..<3 {
let logs = rotator.readAllLogs()
XCTAssertEqual(logs.count, 300)
}
// After ~256 leaked descriptors, this open fails
let testFile = dir.appendingPathComponent("canary.txt")
try Data("test".utf8).write(to: testFile)
let canary = FileHandle(forReadingAtPath: testFile.path)
XCTAssertNotNil(canary,
"Cannot open file — descriptor exhaustion from leaked handles")
canary?.closeFile()
}
}Correct (closes file handle with defer, preventing descriptor leaks):
import Foundation
class LogRotator {
private let logDirectory: URL
init(logDirectory: URL) {
self.logDirectory = logDirectory
}
func readAllLogs() -> [String] {
var contents: [String] = []
let files = (try? FileManager.default.contentsOfDirectory(
at: logDirectory, includingPropertiesForKeys: nil)) ?? []
for file in files {
guard let handle = FileHandle(forReadingAtPath: file.path) else {
continue
}
defer { handle.closeFile() } // always closes, even on error
let data = handle.readDataToEndOfFile()
if let text = String(data: data, encoding: .utf8) {
contents.append(text)
}
}
return contents
}
func appendToLog(name: String, message: String) {
let url = logDirectory.appendingPathComponent(name)
guard let handle = FileHandle(forWritingAtPath: url.path) else {
return
}
defer { handle.closeFile() }
handle.seekToEndOfFile()
handle.write(Data(message.utf8))
}
}Low Memory Warning Ignored Triggers Jetsam Kill
When iOS sends UIApplication.didReceiveMemoryWarningNotification, apps that do not shed cached data are killed by the Jetsam reaper. This produces no crash log and no stack trace. Users see the app restart from scratch with no explanation.
Incorrect (ignores memory warnings, keeping all cached images in memory):
import UIKit
class ImageGalleryCache {
private var cache: [String: UIImage] = [:]
func store(image: UIImage, forKey key: String) {
cache[key] = image
}
func image(forKey key: String) -> UIImage? {
cache[key]
}
func preloadGallery(urls: [String]) {
for url in urls {
// Stores full-resolution images with no eviction policy
let image = UIImage(systemName: "photo")!
cache[url] = image
}
}
var cachedCount: Int { cache.count }
// No memory warning handling — Jetsam kills the app
}Proof Test (simulates memory warning, verifies cache is purged):
import XCTest
import UIKit
final class ImageGalleryCacheMemoryTests: XCTestCase {
func testCacheRespondsToMemoryWarning() {
let cache = ImageGalleryCache()
// Preload many images
let urls = (0..<500).map { "https://example.com/img/\($0).jpg" }
cache.preloadGallery(urls: urls)
XCTAssertEqual(cache.cachedCount, 500)
// Simulate memory warning
NotificationCenter.default.post(
name: UIApplication.didReceiveMemoryWarningNotification,
object: nil
)
// Cache should be cleared to prevent Jetsam kill
XCTAssertEqual(cache.cachedCount, 0,
"Cache not cleared on memory warning — app will be killed by Jetsam")
}
}Correct (subscribes to memory warning and purges cache to avoid termination):
import UIKit
class ImageGalleryCache {
private var cache: [String: UIImage] = [:]
init() {
// Subscribe to memory warnings
NotificationCenter.default.addObserver(
self,
selector: #selector(handleMemoryWarning),
name: UIApplication.didReceiveMemoryWarningNotification,
object: nil
)
}
func store(image: UIImage, forKey key: String) {
cache[key] = image
}
func image(forKey key: String) -> UIImage? {
cache[key]
}
func preloadGallery(urls: [String]) {
for url in urls {
let image = UIImage(systemName: "photo")!
cache[url] = image
}
}
@objc private func handleMemoryWarning() {
cache.removeAll() // shed memory to survive Jetsam
}
var cachedCount: Int { cache.count }
deinit {
NotificationCenter.default.removeObserver(self)
}
}GCD Creates Unbounded Threads Under Concurrent Load
Dispatching blocking work to .concurrent queues causes GCD to spawn additional threads when existing ones are blocked. With 500 blocking operations, GCD creates 500+ threads. The kernel enforces a per-process thread limit (~512) and terminates the process when exceeded.
Incorrect (dispatches blocking work to concurrent queue, causing thread explosion):
import Foundation
class DataImporter {
private let processingQueue = DispatchQueue(
label: "com.app.import",
attributes: .concurrent // GCD spawns threads for each blocked item
)
func importAll(records: [Data], completion: @escaping ([String]) -> Void) {
var results: [String] = []
let lock = NSLock()
let group = DispatchGroup()
for record in records {
group.enter()
processingQueue.async {
// Simulates blocking I/O — GCD spawns a new thread per block
Thread.sleep(forTimeInterval: 0.1)
let parsed = self.parse(record)
lock.lock()
results.append(parsed)
lock.unlock()
group.leave()
}
}
group.notify(queue: .main) {
completion(results)
}
}
private func parse(_ data: Data) -> String {
String(data: data, encoding: .utf8) ?? ""
}
}Proof Test (exposes thread explosion by dispatching 500 blocking operations):
import XCTest
final class DataImporterThreadTests: XCTestCase {
func testBulkImportDoesNotExplodeThreads() {
let importer = DataImporter()
let records = (0..<500).map { Data("record-\($0)".utf8) }
let expectation = expectation(description: "import complete")
var threadCountBefore = 0
var threadCountDuring = 0
threadCountBefore = currentThreadCount()
importer.importAll(records: records) { results in
XCTAssertEqual(results.count, 500)
expectation.fulfill()
}
// Sample thread count during processing
DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) {
threadCountDuring = self.currentThreadCount()
}
wait(for: [expectation], timeout: 60)
let spawned = threadCountDuring - threadCountBefore
// GCD creates 500+ threads with concurrent queue + blocking work
XCTAssertLessThan(spawned, 20,
"Spawned \(spawned) threads — thread explosion detected")
}
private func currentThreadCount() -> Int {
var threadList: thread_act_array_t?
var threadCount: mach_msg_type_number_t = 0
task_threads(mach_task_self_, &threadList, &threadCount)
return Int(threadCount)
}
}Correct (OperationQueue limits concurrent operations, preventing thread explosion):
import Foundation
class DataImporter {
private let operationQueue: OperationQueue = {
let queue = OperationQueue()
queue.name = "com.app.import"
queue.maxConcurrentOperationCount = 4 // bounded thread count
return queue
}()
func importAll(records: [Data], completion: @escaping ([String]) -> Void) {
var results: [String] = []
let lock = NSLock()
let group = DispatchGroup()
for record in records {
group.enter()
operationQueue.addOperation {
Thread.sleep(forTimeInterval: 0.1)
let parsed = self.parse(record)
lock.lock()
results.append(parsed)
lock.unlock()
group.leave()
}
}
group.notify(queue: .main) {
completion(results)
}
}
private func parse(_ data: Data) -> String {
String(data: data, encoding: .utf8) ?? ""
}
}Unbounded Task Spawning in Loop Exhausts Memory
Spawning a Task per item in a large collection creates thousands of concurrent tasks simultaneously. Each task allocates its own stack and continuation, and without backpressure the memory footprint climbs past 200MB. iOS terminates the app with no crash log.
Incorrect (spawns one task per item with no concurrency limit):
import UIKit
class ThumbnailGenerator {
private let cache = NSCache<NSString, UIImage>()
func generateAll(for urls: [URL]) async -> [URL: UIImage] {
var results: [URL: UIImage] = [:]
await withTaskGroup(of: (URL, UIImage?).self) { group in
for url in urls {
group.addTask {
// All 10,000 tasks launch simultaneously
let image = await self.downloadAndResize(url)
return (url, image)
}
}
for await (url, image) in group {
if let image { results[url] = image }
}
}
return results
}
private func downloadAndResize(_ url: URL) async -> UIImage? {
try? await Task.sleep(for: .milliseconds(50))
return UIImage()
}
}Proof Test (exposes memory spike from unbounded task creation):
import XCTest
final class ThumbnailGeneratorMemoryTests: XCTestCase {
func testBulkGenerationDoesNotExhaustMemory() async {
let generator = ThumbnailGenerator()
let urls = (0..<10_000).map {
URL(string: "https://example.com/img/\($0).jpg")!
}
let beforeMB = memoryUsageMB()
_ = await generator.generateAll(for: urls)
let afterMB = memoryUsageMB()
let spikeMB = afterMB - beforeMB
// Unbounded spawning creates 200MB+ spike
XCTAssertLessThan(spikeMB, 50.0,
"Memory spiked by \(spikeMB)MB — unbounded task spawning")
}
private func memoryUsageMB() -> Double {
var info = mach_task_basic_info()
var count = mach_msg_type_number_t(
MemoryLayout<mach_task_basic_info>.size / MemoryLayout<natural_t>.size)
let result = withUnsafeMutablePointer(to: &info) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO),
$0, &count)
}
}
return result == KERN_SUCCESS
? Double(info.resident_size) / 1_048_576.0
: 0
}
}Correct (limits concurrency with batched task group processing):
import UIKit
class ThumbnailGenerator {
private let cache = NSCache<NSString, UIImage>()
private let maxConcurrency = 8
func generateAll(for urls: [URL]) async -> [URL: UIImage] {
var results: [URL: UIImage] = [:]
await withTaskGroup(of: (URL, UIImage?).self) { group in
var iterator = urls.makeIterator()
// Seed the group with limited concurrent tasks
for _ in 0..<maxConcurrency {
guard let url = iterator.next() else { break }
group.addTask { (url, await self.downloadAndResize(url)) }
}
// As each completes, add the next — constant backpressure
for await (url, image) in group {
if let image { results[url] = image }
if let nextURL = iterator.next() {
group.addTask {
(nextURL, await self.downloadAndResize(nextURL))
}
}
}
}
return results
}
private func downloadAndResize(_ url: URL) async -> UIImage? {
try? await Task.sleep(for: .milliseconds(50))
return UIImage()
}
}URLSession Not Invalidated Leaks Delegate and Connections
URLSession retains its delegate strongly. Without calling invalidateAndCancel() or finishTasksAndInvalidate(), both the session and its delegate are never deallocated. Creating a session per request leaks memory linearly, eventually exhausting the app's allocation.
Incorrect (creates a new session per request, leaking delegate each time):
import Foundation
class NetworkManager: NSObject, URLSessionDataDelegate {
var completionHandler: ((Data?) -> Void)?
func fetch(url: URL, completion: @escaping (Data?) -> Void) {
completionHandler = completion
let config = URLSessionConfiguration.default
// New session per request — retains self as delegate forever
let session = URLSession(configuration: config,
delegate: self, delegateQueue: nil)
let task = session.dataTask(with: url)
task.resume()
// Session never invalidated — self is leaked
}
func urlSession(_ session: URLSession,
task: URLSessionTask,
didCompleteWithError error: Error?) {
completionHandler?(nil)
}
deinit {
print("NetworkManager deallocated")
}
}Proof Test (exposes the delegate leak by verifying deallocation):
import XCTest
final class NetworkManagerLeakTests: XCTestCase {
func testNetworkManagerDeallocatesAfterRequest() async throws {
weak var weakManager: NetworkManager?
try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<Void, Error>) in
let manager = NetworkManager()
weakManager = manager
let url = URL(string: "https://example.com")!
manager.fetch(url: url) { _ in
continuation.resume()
}
}
// Give time for deallocation
try await Task.sleep(for: .seconds(1))
// weakManager should be nil if properly deallocated
XCTAssertNil(weakManager,
"NetworkManager was not deallocated — URLSession retains delegate")
}
}Correct (invalidates session after use, releasing delegate reference):
import Foundation
class NetworkManager: NSObject, URLSessionDataDelegate {
private var session: URLSession?
var completionHandler: ((Data?) -> Void)?
func fetch(url: URL, completion: @escaping (Data?) -> Void) {
completionHandler = completion
let config = URLSessionConfiguration.default
let session = URLSession(configuration: config,
delegate: self, delegateQueue: nil)
self.session = session
let task = session.dataTask(with: url)
task.resume()
}
func urlSession(_ session: URLSession,
task: URLSessionTask,
didCompleteWithError error: Error?) {
completionHandler?(nil)
// Invalidate releases the delegate reference
session.finishTasksAndInvalidate()
self.session = nil
}
deinit {
print("NetworkManager deallocated")
}
}Concurrent File Writes Corrupt Data Without Coordination
Two threads writing to the same file simultaneously produce interleaved or truncated output. The OS does not serialize writes to the same path — one thread's write can overwrite another's mid-byte, corrupting the log permanently.
Incorrect (interleaves writes from concurrent callers, corrupting file content):
import Foundation
class LogFileWriter {
private let fileURL: URL
init(fileURL: URL) {
self.fileURL = fileURL
FileManager.default.createFile(atPath: fileURL.path, contents: nil)
}
func append(_ message: String) {
let handle = try! FileHandle(forWritingTo: fileURL)
handle.seekToEndOfFile()
let data = Data("\(message)\n".utf8)
handle.write(data) // concurrent calls interleave bytes here
handle.closeFile()
}
func readAll() -> String {
(try? String(contentsOf: fileURL, encoding: .utf8)) ?? ""
}
}Proof Test (exposes corrupted output from 100 concurrent writes):
import XCTest
final class LogFileWriterConcurrencyTests: XCTestCase {
func testConcurrentWritesProduceCompleteLines() async throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("test-\(UUID()).log")
let writer = LogFileWriter(fileURL: url)
let lineCount = 100
await withTaskGroup(of: Void.self) { group in
for i in 0..<lineCount {
group.addTask {
writer.append("line-\(i)") // races here
}
}
}
let contents = writer.readAll()
let lines = contents.split(separator: "\n")
// Corrupted writes produce fewer valid lines or merged text
XCTAssertEqual(lines.count, lineCount,
"Expected \(lineCount) lines but got \(lines.count) — data corrupted")
}
}Correct (serial queue coordinates all writes, eliminating interleaving):
import Foundation
class LogFileWriter {
private let fileURL: URL
private let writeQueue = DispatchQueue(label: "com.app.logwriter")
init(fileURL: URL) {
self.fileURL = fileURL
FileManager.default.createFile(atPath: fileURL.path, contents: nil)
}
func append(_ message: String) {
writeQueue.sync { // serializes all file access
let handle = try! FileHandle(forWritingTo: fileURL)
handle.seekToEndOfFile()
let data = Data("\(message)\n".utf8)
handle.write(data)
handle.closeFile()
}
}
func readAll() -> String {
writeQueue.sync {
(try? String(contentsOf: fileURL, encoding: .utf8)) ?? ""
}
}
}CoreData NSManagedObject Accessed from Wrong Thread
CoreData enforces thread confinement: an NSManagedObject must only be accessed on the queue that owns its NSManagedObjectContext. Passing the object to a background thread violates this contract, triggering a SIGABRT in debug or silent data corruption in release builds.
Incorrect (passes managed object to background thread, violating confinement):
import CoreData
class OrderRepository {
let viewContext: NSManagedObjectContext
init(viewContext: NSManagedObjectContext) {
self.viewContext = viewContext
}
func processOrder(_ order: Order) {
DispatchQueue.global().async {
// Accessing managed object off its context's queue — SIGABRT
let total = order.totalAmount
let name = order.customerName
self.sendReceipt(name: name, amount: total)
}
}
private func sendReceipt(name: String, amount: Double) {
print("Receipt sent to \(name) for \(amount)")
}
}Proof Test (exposes the crash by accessing object from wrong thread):
import XCTest
import CoreData
final class OrderRepositoryThreadTests: XCTestCase {
func testProcessOrderDoesNotCrashFromBackground() async throws {
let container = NSPersistentContainer(name: "Model")
container.loadPersistentStores { _, error in
XCTAssertNil(error)
}
let context = container.viewContext
let order = Order(context: context)
order.customerName = "Alice"
order.totalAmount = 99.99
try context.save()
let repo = OrderRepository(viewContext: context)
// With -com.apple.CoreData.ConcurrencyDebug 1,
// this crashes on background access
let expectation = expectation(description: "receipt sent")
repo.processOrder(order)
DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
expectation.fulfill()
}
await fulfillment(of: [expectation], timeout: 3)
}
}Correct (passes objectID and refetches in background context):
import CoreData
class OrderRepository {
let viewContext: NSManagedObjectContext
let container: NSPersistentContainer
init(container: NSPersistentContainer) {
self.container = container
self.viewContext = container.viewContext
}
func processOrder(_ order: Order) {
let objectID = order.objectID // thread-safe identifier
container.performBackgroundTask { bgContext in
let bgOrder = bgContext.object(with: objectID) as! Order
// Safe — accessing on bgContext's queue
let total = bgOrder.totalAmount
let name = bgOrder.customerName
self.sendReceipt(name: name, amount: total)
}
}
private func sendReceipt(name: String, amount: Double) {
print("Receipt sent to \(name) for \(amount)")
}
}FileManager Existence Check Then Use Is a TOCTOU Race
Calling fileExists() before a file operation creates a Time-Of-Check-Time-Of-Use (TOCTOU) gap. Between the check and the use, another thread or process can delete, move, or create the file. The pre-check gives false confidence; the operation fails anyway.
Incorrect (TOCTOU gap between existence check and file operation):
import Foundation
class CacheManager {
private let cacheDir: URL
init(cacheDir: URL) {
self.cacheDir = cacheDir
}
func writeCacheFile(name: String, data: Data) throws {
let fileURL = cacheDir.appendingPathComponent(name)
// TOCTOU: file can be deleted between check and write
if !FileManager.default.fileExists(atPath: cacheDir.path) {
try FileManager.default.createDirectory(
at: cacheDir, withIntermediateDirectories: true)
}
try data.write(to: fileURL)
}
func readCacheFile(name: String) -> Data? {
let fileURL = cacheDir.appendingPathComponent(name)
// TOCTOU: file can be deleted between check and read
guard FileManager.default.fileExists(atPath: fileURL.path) else {
return nil
}
return FileManager.default.contents(atPath: fileURL.path)
}
}Proof Test (exposes the TOCTOU gap by racing deletion with read):
import XCTest
final class CacheManagerTOCTOUTests: XCTestCase {
func testConcurrentDeleteAndReadDoNotCrash() async throws {
let cacheDir = FileManager.default.temporaryDirectory
.appendingPathComponent("toctou-test-\(UUID())")
let manager = CacheManager(cacheDir: cacheDir)
let data = Data("cached-content".utf8)
try manager.writeCacheFile(name: "test.dat", data: data)
var failures = 0
let iterations = 200
for _ in 0..<iterations {
try manager.writeCacheFile(name: "test.dat", data: data)
async let reading: Data? = Task {
manager.readCacheFile(name: "test.dat")
}.value
async let deleting: Void = Task {
try? FileManager.default.removeItem(
at: cacheDir.appendingPathComponent("test.dat"))
}.value
_ = await deleting
let result = await reading
// With TOCTOU, fileExists returns true but read returns nil
if result == nil { failures += 1 }
}
// TOCTOU failures should be zero with correct code
XCTAssertEqual(failures, 0,
"TOCTOU caused \(failures) read failures out of \(iterations)")
}
}Correct (try the operation directly, handle errors instead of pre-checking):
import Foundation
class CacheManager {
private let cacheDir: URL
init(cacheDir: URL) {
self.cacheDir = cacheDir
}
func writeCacheFile(name: String, data: Data) throws {
let fileURL = cacheDir.appendingPathComponent(name)
// Create directory unconditionally — no TOCTOU
try FileManager.default.createDirectory(
at: cacheDir, withIntermediateDirectories: true)
try data.write(to: fileURL, options: .atomic)
}
func readCacheFile(name: String) -> Data? {
let fileURL = cacheDir.appendingPathComponent(name)
// Try directly — no existence check, no TOCTOU gap
return try? Data(contentsOf: fileURL)
}
}Keychain Access from Multiple Threads Returns Unexpected Errors
The Security framework's SecItemCopyMatching and SecItemUpdate are not fully thread-safe when called concurrently. Racing reads and writes produce intermittent errSecInteractionNotAllowed or errSecDuplicateItem errors, causing token lookups to fail silently in production.
Incorrect (concurrent keychain access produces intermittent errors):
import Foundation
import Security
class TokenStore {
private let service = "com.app.auth"
func saveToken(_ token: String) throws {
let data = Data(token.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecValueData as String: data
]
SecItemDelete(query as CFDictionary)
// Concurrent calls race between delete and add
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.saveFailed(status)
}
}
func loadToken() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecReturnData as String: true
]
var result: AnyObject?
// Concurrent read during write returns unexpected errors
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
return nil
}
return String(data: data, encoding: .utf8)
}
}
enum KeychainError: Error {
case saveFailed(OSStatus)
}Proof Test (exposes intermittent errors from concurrent keychain access):
import XCTest
final class TokenStoreConcurrencyTests: XCTestCase {
func testConcurrentSaveAndLoadDoNotFail() async {
let store = TokenStore()
var errors = 0
let iterations = 50
await withTaskGroup(of: Bool.self) { group in
for i in 0..<iterations {
group.addTask {
do {
try store.saveToken("token-\(i)")
return true
} catch {
return false // race-induced failure
}
}
group.addTask {
return store.loadToken() != nil || true
}
}
for await success in group {
if !success { errors += 1 }
}
}
XCTAssertEqual(errors, 0,
"\(errors) keychain operations failed due to concurrent access")
}
}Correct (actor serializes all keychain operations, eliminating races):
import Foundation
import Security
actor TokenStore {
private let service = "com.app.auth"
func saveToken(_ token: String) throws {
let data = Data(token.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecValueData as String: data
]
SecItemDelete(query as CFDictionary)
// Actor ensures serial access — no race between delete and add
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.saveFailed(status)
}
}
func loadToken() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
return nil
}
return String(data: data, encoding: .utf8)
}
}
enum KeychainError: Error {
case saveFailed(OSStatus)
}UserDefaults Concurrent Read-Write Produces Stale Values
UserDefaults is thread-safe for individual read or write operations, but compound check-then-write sequences are NOT atomic. Two threads can both read the old value, both decide to increment, and one write clobbers the other. The lost update goes undetected.
Incorrect (check-then-write sequence races, losing increments):
import Foundation
class FeatureFlagStore {
private let defaults = UserDefaults.standard
private let key = "launch_count"
func incrementLaunchCount() -> Int {
let current = defaults.integer(forKey: key) // read
let next = current + 1
defaults.set(next, forKey: key) // write — not atomic with read
return next
}
func launchCount() -> Int {
defaults.integer(forKey: key)
}
func reset() {
defaults.removeObject(forKey: key)
}
}Proof Test (exposes lost updates from concurrent increments):
import XCTest
final class FeatureFlagStoreConcurrencyTests: XCTestCase {
func testConcurrentIncrementsAreNotLost() async {
let store = FeatureFlagStore()
store.reset()
let iterations = 200
await withTaskGroup(of: Void.self) { group in
for _ in 0..<iterations {
group.addTask {
_ = store.incrementLaunchCount() // races here
}
}
}
let finalCount = store.launchCount()
// Lost updates produce a count less than iterations
XCTAssertEqual(finalCount, iterations,
"Expected \(iterations) but got \(finalCount) — updates lost")
}
}Correct (actor serializes the check-then-write into a single atomic operation):
import Foundation
actor FeatureFlagStore {
private let defaults = UserDefaults.standard
private let key = "launch_count"
func incrementLaunchCount() -> Int {
let current = defaults.integer(forKey: key)
let next = current + 1
defaults.set(next, forKey: key) // serialized by actor — no race
return next
}
func launchCount() -> Int {
defaults.integer(forKey: key)
}
func reset() {
defaults.removeObject(forKey: key)
}
}SwiftData Model Accessed from Wrong ModelContext
SwiftData models are bound to their ModelContext and its actor. Passing a @Model object to a background task accesses it outside its isolation boundary, causing a crash or silent data corruption. The model's properties are not thread-safe.
Incorrect (sends SwiftData model to background task, violating actor isolation):
import SwiftData
import Foundation
@Model
class Bookmark {
var title: String
var url: String
var isSynced: Bool
init(title: String, url: String) {
self.title = title
self.url = url
self.isSynced = false
}
}
@MainActor
class BookmarkManager {
let modelContext: ModelContext
init(modelContext: ModelContext) {
self.modelContext = modelContext
}
func syncBookmark(_ bookmark: Bookmark) {
Task.detached {
// Accessing @Model off its ModelContext actor — crash
bookmark.isSynced = true
try await self.uploadToServer(title: bookmark.title)
}
}
private func uploadToServer(title: String) async throws {
try await Task.sleep(for: .milliseconds(100))
}
}Proof Test (exposes the crash by mutating model on wrong actor):
import XCTest
import SwiftData
final class BookmarkManagerThreadTests: XCTestCase {
@MainActor
func testSyncBookmarkDoesNotCrash() async throws {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(
for: Bookmark.self, configurations: config
)
let context = container.mainContext
let bookmark = Bookmark(title: "Apple", url: "https://apple.com")
context.insert(bookmark)
try context.save()
let manager = BookmarkManager(modelContext: context)
manager.syncBookmark(bookmark)
// Allow background task to execute
try await Task.sleep(for: .seconds(1))
// With incorrect code, crash occurs before reaching this assertion
XCTAssertTrue(bookmark.isSynced)
}
}Correct (transfers PersistentIdentifier and refetches in background context):
import SwiftData
import Foundation
@MainActor
class BookmarkManager {
let modelContainer: ModelContainer
let modelContext: ModelContext
init(modelContainer: ModelContainer) {
self.modelContainer = modelContainer
self.modelContext = modelContainer.mainContext
}
func syncBookmark(_ bookmark: Bookmark) {
let id = bookmark.persistentModelID // thread-safe identifier
Task.detached {
let bgContext = ModelContext(self.modelContainer)
guard let bgBookmark = bgContext.model(for: id) as? Bookmark else {
return
}
// Safe — accessing on bgContext's owning thread
try await self.uploadToServer(title: bgBookmark.title)
bgBookmark.isSynced = true
try bgContext.save()
}
}
private func uploadToServer(title: String) async throws {
try await Task.sleep(for: .milliseconds(100))
}
}Task Captures Self Extending Lifetime Beyond Expected Scope
Task { self.fetchData() } implicitly captures self strongly. If the Task performs a long-running operation (network call, pagination), it keeps the ViewModel alive well after the user has left the screen. The ViewModel processes stale data, updates phantom UI state, and is only freed when the task finally completes.
Incorrect (ViewModel survives screen dismissal during long fetch):
import Observation
@Observable
final class OrderListViewModel {
var orders: [Order] = []
var isLoading = false
private let orderService: OrderService
init(orderService: OrderService) {
self.orderService = orderService
}
func loadOrders() {
isLoading = true
Task {
let fetched = try await orderService.fetchAllOrders() // 5-10s network call
self.orders = fetched // strong self — ViewModel alive until task finishes
self.isLoading = false
}
}
deinit { print("OrderListViewModel deallocated") }
}Proof Test (exposes extended lifetime — ViewModel not freed after scope exits):
import XCTest
@testable import MyApp
final class AsyncTaskSelfCaptureTests: XCTestCase {
func testViewModelDeallocatesAfterScreenDismissal() async throws {
weak var weakVM: OrderListViewModel?
let service = SlowOrderService() // returns after 2 seconds
autoreleasepool {
let vm = OrderListViewModel(orderService: service)
weakVM = vm
vm.loadOrders()
// Screen dismissed — all strong references dropped
}
// Give task time to still be in-flight
try await Task.sleep(for: .milliseconds(100))
// ViewModel still alive — Task holds strong reference
XCTAssertNil(weakVM, "OrderListViewModel leaked — Task extended its lifetime")
}
}Correct (weak self in Task, cancelled via structured concurrency):
import Observation
@Observable
final class OrderListViewModel {
var orders: [Order] = []
var isLoading = false
private let orderService: OrderService
private var loadTask: Task<Void, Never>?
init(orderService: OrderService) {
self.orderService = orderService
}
func loadOrders() {
isLoading = true
loadTask = Task { [weak self] in // weak self — ViewModel can deallocate freely
guard let self else { return }
do {
let fetched = try await orderService.fetchAllOrders()
guard !Task.isCancelled else { return } // respect cancellation
self.orders = fetched
self.isLoading = false
} catch {
self.isLoading = false
}
}
}
deinit {
loadTask?.cancel() // deterministic cleanup on deallocation
print("OrderListViewModel deallocated")
}
}Strong Self Capture in Escaping Closures Creates Retain Cycle
When an @Observable ViewModel stores an escaping closure that captures self strongly, neither the ViewModel nor the closure can be freed. The reference count never reaches zero because each object holds the other alive, leaking the ViewModel on every navigation.
Incorrect (leaks ViewModel on every navigation):
import Foundation
import Observation
@Observable
final class ProfileViewModel {
var userName: String = ""
var onUserFetched: (() -> Void)?
func startFetching() {
onUserFetched = { // strong capture of self — retain cycle
self.userName = "Fetched User"
}
simulateNetworkCall(completion: onUserFetched!)
}
private func simulateNetworkCall(completion: @escaping () -> Void) {
DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
completion()
}
}
deinit { print("ProfileViewModel deallocated") }
}Proof Test (exposes the leak — deinit never fires):
import XCTest
@testable import MyApp
final class ProfileViewModelRetainCycleTests: XCTestCase {
func testViewModelDeallocatesAfterUse() async throws {
weak var weakVM: ProfileViewModel?
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
let vm = ProfileViewModel()
weakVM = vm
vm.startFetching()
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
continuation.resume()
}
}
// weakVM is still non-nil — retain cycle keeps it alive
addTeardownBlock { [weak weakVM] in
XCTAssertNil(weakVM, "ProfileViewModel was not deallocated — retain cycle detected")
}
}
}Correct (weak self breaks the cycle, ViewModel deallocates normally):
import Foundation
import Observation
@Observable
final class ProfileViewModel {
var userName: String = ""
var onUserFetched: (() -> Void)?
func startFetching() {
onUserFetched = { [weak self] in // weak capture breaks retain cycle
self?.userName = "Fetched User"
}
simulateNetworkCall(completion: onUserFetched!)
}
private func simulateNetworkCall(completion: @escaping () -> Void) {
DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
completion()
}
}
deinit { print("ProfileViewModel deallocated") }
}Combine Sink Retains Self Without Cancellable Storage
Calling .sink { self.update($0) } on a publisher captures self strongly. If the returned AnyCancellable is not stored, the subscription is immediately cancelled and the result is silently lost. If the cancellable is stored but self is captured strongly, the subscription keeps the ViewModel alive after the screen is dismissed, processing stale data.
Incorrect (strong self in sink, ViewModel outlives its screen):
import Combine
import Observation
@Observable
final class SearchViewModel {
var results: [String] = []
var query: String = ""
private var cancellables = Set<AnyCancellable>()
private let searchService: SearchService
init(searchService: SearchService) {
self.searchService = searchService
setupSearch()
}
private func setupSearch() {
searchService.resultsPublisher
.receive(on: DispatchQueue.main)
.sink { results in
self.results = results // strong capture — ViewModel never deallocates
}
.store(in: &cancellables)
}
deinit { print("SearchViewModel deallocated") }
}Proof Test (exposes the leak — ViewModel survives after reference dropped):
import XCTest
import Combine
@testable import MyApp
final class CombineSinkRetainTests: XCTestCase {
func testViewModelDeallocatesWhenScreenDismissed() {
weak var weakVM: SearchViewModel?
let service = SearchService()
autoreleasepool {
let vm = SearchViewModel(searchService: service)
weakVM = vm
}
// Publisher still holds strong reference to ViewModel via sink closure
XCTAssertNil(weakVM, "SearchViewModel leaked — sink closure retained self")
}
}Correct (weak self in sink, cancellables auto-cancel on deallocation):
import Combine
import Observation
@Observable
final class SearchViewModel {
var results: [String] = []
var query: String = ""
private var cancellables = Set<AnyCancellable>()
private let searchService: SearchService
init(searchService: SearchService) {
self.searchService = searchService
setupSearch()
}
private func setupSearch() {
searchService.resultsPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] results in // weak self — subscription dies with ViewModel
self?.results = results
}
.store(in: &cancellables)
}
deinit { print("SearchViewModel deallocated") }
}Strong Delegate Reference Prevents Deallocation
A delegate property declared as var delegate: SomeDelegate creates a strong reference to the delegate. When the delegate (typically a ViewController) also holds a strong reference to the delegating object (a MediaPlayer), a retain cycle forms. The entire ViewController hierarchy leaks on every navigation push/pop.
Incorrect (leaks PlayerViewController on every navigation pop):
import Foundation
protocol MediaPlayerDelegate: AnyObject {
func playerDidFinish(_ player: MediaPlayer)
}
final class MediaPlayer {
var delegate: MediaPlayerDelegate? // strong reference — retains the delegate
private var trackURL: URL?
func play(url: URL) {
trackURL = url
DispatchQueue.global().asyncAfter(deadline: .now() + 2) {
self.delegate?.playerDidFinish(self)
}
}
deinit { print("MediaPlayer deallocated") }
}
final class PlayerViewController: UIViewController, MediaPlayerDelegate {
private let player = MediaPlayer() // VC owns player
override func viewDidLoad() {
super.viewDidLoad()
player.delegate = self // player now owns VC — cycle formed
}
func playerDidFinish(_ player: MediaPlayer) {
print("Playback complete")
}
deinit { print("PlayerViewController deallocated") }
}Proof Test (exposes the leak — neither object deallocates):
import XCTest
@testable import MyApp
final class MediaPlayerDelegateLeakTests: XCTestCase {
func testPlayerViewControllerDeallocatesAfterDismissal() {
weak var weakVC: PlayerViewController?
weak var weakPlayer: MediaPlayer?
autoreleasepool {
let vc = PlayerViewController()
weakVC = vc
vc.loadViewIfNeeded()
// Both vc and its player should be released here
}
XCTAssertNil(weakVC, "PlayerViewController leaked — delegate retained it")
}
}Correct (weak delegate breaks the cycle, both objects deallocate):
import Foundation
protocol MediaPlayerDelegate: AnyObject {
func playerDidFinish(_ player: MediaPlayer)
}
final class MediaPlayer {
weak var delegate: MediaPlayerDelegate? // weak breaks the retain cycle
private var trackURL: URL?
func play(url: URL) {
trackURL = url
DispatchQueue.global().asyncAfter(deadline: .now() + 2) {
self.delegate?.playerDidFinish(self)
}
}
deinit { print("MediaPlayer deallocated") }
}
final class PlayerViewController: UIViewController, MediaPlayerDelegate {
private let player = MediaPlayer()
override func viewDidLoad() {
super.viewDidLoad()
player.delegate = self // safe — player holds weak reference
}
func playerDidFinish(_ player: MediaPlayer) {
print("Playback complete")
}
deinit { print("PlayerViewController deallocated") }
}NotificationCenter Observer Retains Closure After Removal Needed
NotificationCenter.default.addObserver(forName:using:) returns an opaque token. If this token is not removed before the owning object deallocates, the closure fires on a freed instance. The observer becomes a zombie that processes stale state and crashes with EXC_BAD_ACCESS the next time the notification posts.
Incorrect (observer fires after ViewModel deallocates):
import Foundation
import Observation
@Observable
final class SettingsViewModel {
var lastSyncDate: Date?
private var observerToken: Any?
init() {
observerToken = NotificationCenter.default.addObserver(
forName: .NSManagedObjectContextDidSave,
object: nil,
queue: .main
) { _ in
self.lastSyncDate = Date() // captures self strongly — zombie closure
}
}
deinit {
// observerToken is never removed — closure outlives self
print("SettingsViewModel deallocated")
}
}Proof Test (exposes the zombie — observer fires after deallocation):
import XCTest
@testable import MyApp
final class NotificationObserverLeakTests: XCTestCase {
func testViewModelDeallocatesAndObserverIsRemoved() {
weak var weakVM: SettingsViewModel?
autoreleasepool {
let vm = SettingsViewModel()
weakVM = vm
}
// Observer closure retains self — ViewModel is not deallocated
XCTAssertNil(weakVM, "SettingsViewModel leaked — observer closure retained self")
// Posting after expected deallocation would crash a zombie instance
NotificationCenter.default.post(
name: .NSManagedObjectContextDidSave,
object: nil
)
}
}Correct (weak self in closure, token removed in deinit):
import Foundation
import Observation
@Observable
final class SettingsViewModel {
var lastSyncDate: Date?
private var observerToken: Any?
init() {
observerToken = NotificationCenter.default.addObserver(
forName: .NSManagedObjectContextDidSave,
object: nil,
queue: .main
) { [weak self] _ in // weak self prevents retention
self?.lastSyncDate = Date()
}
}
deinit {
if let token = observerToken {
NotificationCenter.default.removeObserver(token) // deterministic cleanup
}
print("SettingsViewModel deallocated")
}
}Timer Retains Target Creating Undiscoverable Retain Cycle
Timer.scheduledTimer(target:selector:) retains its target strongly for the lifetime of the timer. If the target (a ViewController) owns the timer, a bidirectional strong reference forms. Neither deinit nor invalidate is ever called, leaking the entire view hierarchy on every navigation.
Incorrect (ViewController never deallocates while timer runs):
import UIKit
final class DashboardViewController: UIViewController {
private var pollingTimer: Timer?
private var dashboardData: [String] = []
override func viewDidLoad() {
super.viewDidLoad()
pollingTimer = Timer.scheduledTimer(
timeInterval: 5.0,
target: self, // timer retains self strongly
selector: #selector(fetchDashboard),
userInfo: nil,
repeats: true
)
}
@objc private func fetchDashboard() {
dashboardData.append("Polled at \(Date())")
}
deinit {
pollingTimer?.invalidate() // never reached — deinit requires deallocation
print("DashboardViewController deallocated")
}
}Proof Test (exposes the leak — deinit never fires after dismissal):
import XCTest
@testable import MyApp
final class DashboardTimerRetainTests: XCTestCase {
func testViewControllerDeallocatesAfterDismissal() {
weak var weakVC: DashboardViewController?
autoreleasepool {
let vc = DashboardViewController()
weakVC = vc
vc.loadViewIfNeeded()
// Simulate dismissal by dropping all strong references
}
// Timer still holds vc alive — weakVC is non-nil
XCTAssertNil(weakVC, "DashboardViewController leaked — timer retained target")
}
}Correct (block-based timer with weak self, invalidated in viewDidDisappear):
import UIKit
final class DashboardViewController: UIViewController {
private var pollingTimer: Timer?
private var dashboardData: [String] = []
override func viewDidLoad() {
super.viewDidLoad()
pollingTimer = Timer.scheduledTimer(
withTimeInterval: 5.0,
repeats: true
) { [weak self] _ in // block-based API — no target retention
self?.fetchDashboard()
}
}
private func fetchDashboard() {
dashboardData.append("Polled at \(Date())")
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
pollingTimer?.invalidate() // deterministic cleanup, does not depend on deinit
pollingTimer = nil
}
deinit { print("DashboardViewController deallocated") }
}Unowned Reference Crashes After Owner Deallocation
unowned assumes the referenced object outlives the referencing object. When lifecycle assumptions break -- such as an async callback returning after a coordinator's parent has been dismissed -- unowned dereferences a freed pointer and triggers an immediate EXC_BAD_ACCESS. This crash is deterministic once timing shifts, but invisible during development.
Incorrect (crashes when parent deallocates before coordinator finishes):
import UIKit
final class AppCoordinator {
var childCoordinators: [Any] = []
func startCheckout() {
let checkout = CheckoutCoordinator(parent: self)
childCoordinators.append(checkout)
checkout.begin()
}
deinit { print("AppCoordinator deallocated") }
}
final class CheckoutCoordinator {
unowned let parent: AppCoordinator // assumes parent outlives self
init(parent: AppCoordinator) {
self.parent = parent
}
func begin() {
DispatchQueue.global().asyncAfter(deadline: .now() + 3) {
// EXC_BAD_ACCESS if parent was deallocated during async work
print("Reporting to \(self.parent)")
}
}
}Proof Test (exposes the crash — accessing unowned after deallocation):
import XCTest
@testable import MyApp
final class UnownedCrashTests: XCTestCase {
func testAccessingUnownedAfterDeallocationCrashes() {
var coordinator: CheckoutCoordinator?
autoreleasepool {
let parent = AppCoordinator()
coordinator = CheckoutCoordinator(parent: parent)
// parent is deallocated here
}
// This access triggers EXC_BAD_ACCESS — unowned references a freed object
// In a real test, this would crash the test runner
XCTAssertNotNil(coordinator, "Coordinator exists but its parent is freed")
// coordinator!.parent would crash here — proves unowned is unsafe
}
}Correct (weak reference with guard-let, survives parent deallocation):
import UIKit
final class AppCoordinator {
var childCoordinators: [Any] = []
func startCheckout() {
let checkout = CheckoutCoordinator(parent: self)
childCoordinators.append(checkout)
checkout.begin()
}
deinit { print("AppCoordinator deallocated") }
}
final class CheckoutCoordinator {
weak var parent: AppCoordinator? // weak — survives parent deallocation
init(parent: AppCoordinator) {
self.parent = parent
}
func begin() {
DispatchQueue.global().asyncAfter(deadline: .now() + 3) { [weak self] in
guard let self, let parent = self.parent else { return } // safe unwrap
print("Reporting to \(parent)")
}
}
}Non-Exhaustive Switch Crashes on Unknown Enum Case
Switching on an enum decoded from an API response without a default case crashes when the server adds new values. The client's switch statement is exhaustive at compile time but not at runtime — the raw value initializer produces a case the switch doesn't handle.
Incorrect (switch without default case crashes on unknown server value):
import Foundation
enum PaymentStatus: String, Decodable {
case pending
case completed
case failed
case refunded
}
class PaymentStatusMapper {
func displayText(for status: PaymentStatus) -> String {
switch status {
case .pending: return "Processing your payment..."
case .completed: return "Payment successful"
case .failed: return "Payment failed"
case .refunded: return "Refund issued"
// No default — future cases crash at runtime
}
}
func mapFromAPI(rawValue: String) -> String {
// Force-creates enum — crashes on unknown values
let status = PaymentStatus(rawValue: rawValue)!
return displayText(for: status)
}
}Proof Test (exposes the crash when server sends a new status value):
import XCTest
final class PaymentStatusMapperTests: XCTestCase {
func testUnknownStatusDoesNotCrash() {
let mapper = PaymentStatusMapper()
// Server adds "disputed" status in new API version
let result = mapper.mapFromAPI(rawValue: "disputed")
// Force-unwrap on unknown rawValue crashes
XCTAssertFalse(result.isEmpty,
"Unknown status should produce fallback text, not crash")
}
func testNullStatusDoesNotCrash() {
let mapper = PaymentStatusMapper()
let result = mapper.mapFromAPI(rawValue: "")
XCTAssertFalse(result.isEmpty,
"Empty status should produce fallback text, not crash")
}
}Correct (handles unknown raw values and uses @unknown default for future cases):
import Foundation
enum PaymentStatus: String, Decodable {
case pending
case completed
case failed
case refunded
case unknown
}
class PaymentStatusMapper {
func displayText(for status: PaymentStatus) -> String {
switch status {
case .pending: return "Processing your payment..."
case .completed: return "Payment successful"
case .failed: return "Payment failed"
case .refunded: return "Refund issued"
case .unknown: return "Status unavailable"
@unknown default: return "Status unavailable" // future-proof
}
}
func mapFromAPI(rawValue: String) -> String {
let status = PaymentStatus(rawValue: rawValue) ?? .unknown
return displayText(for: status)
}
}Collection Mutation During Enumeration Crashes at Runtime
Modifying a collection while iterating over it invalidates the iterator. With Swift arrays bridged from NSMutableArray or accessed concurrently, this triggers an NSInternalInconsistencyException. Even pure Swift arrays crash when a captured reference is mutated from another closure during enumeration.
Incorrect (removes elements during for-in enumeration, crashing):
import Foundation
class EventBus {
private var handlers: [String: [() -> Void]] = [:]
func register(event: String, handler: @escaping () -> Void) {
handlers[event, default: []].append(handler)
}
func emit(event: String) {
guard let eventHandlers = handlers[event] else { return }
for handler in eventHandlers {
handler()
}
}
func removeAll(for event: String) {
handlers[event]?.removeAll() // mutation during enumeration if called from handler
}
func emitAndCleanup(event: String) {
guard let eventHandlers = handlers[event] else { return }
for handler in eventHandlers {
handler()
// Mutating handlers mid-iteration — crash
handlers[event]?.removeFirst()
}
}
}Proof Test (exposes crash when mutating collection during iteration):
import XCTest
final class EventBusMutationTests: XCTestCase {
func testEmitAndCleanupDoesNotCrash() {
let bus = EventBus()
var callCount = 0
for _ in 0..<5 {
bus.register(event: "load") {
callCount += 1
}
}
// emitAndCleanup mutates the array mid-iteration — crash
XCTAssertNoThrow(bus.emitAndCleanup(event: "load"))
XCTAssertEqual(callCount, 5,
"All handlers should have been called before cleanup")
}
}Correct (copies handlers before iterating, then clears safely):
import Foundation
class EventBus {
private var handlers: [String: [() -> Void]] = [:]
func register(event: String, handler: @escaping () -> Void) {
handlers[event, default: []].append(handler)
}
func emit(event: String) {
guard let eventHandlers = handlers[event] else { return }
for handler in eventHandlers {
handler()
}
}
func removeAll(for event: String) {
handlers[event]?.removeAll()
}
func emitAndCleanup(event: String) {
let snapshot = handlers[event] ?? [] // copy before iterating
handlers[event]?.removeAll() // mutate the original safely
for handler in snapshot {
handler()
}
}
}Force Unwrapping Optional in Production Crashes on Nil
Force-unwrap (!) assumes a value is always present. When server responses change format, optional fields are removed, or edge cases emerge in production, the assumption fails with a fatal error. The crash occurs at the unwrap site with no recovery path.
Incorrect (force-unwraps API response fields that can be nil):
import Foundation
struct UserProfile {
let name: String
let email: String
let avatarURL: URL?
}
class UserProfileMapper {
func map(from json: [String: Any]) -> UserProfile {
// Server can omit fields or send null — force-unwrap crashes
let name = json["name"] as! String
let email = json["email"] as! String
let avatarString = json["avatar_url"] as! String
let avatarURL = URL(string: avatarString)!
return UserProfile(
name: name,
email: email,
avatarURL: avatarURL
)
}
}Proof Test (exposes the crash when API response contains nil values):
import XCTest
final class UserProfileMapperTests: XCTestCase {
func testMapHandlesMissingFields() {
let mapper = UserProfileMapper()
// Server sends partial response — name is missing
let incompleteJSON: [String: Any] = [
"email": "alice@example.com"
]
// Force-unwrap on missing "name" crashes
let profile = mapper.map(from: incompleteJSON)
XCTAssertEqual(profile.email, "alice@example.com")
}
func testMapHandlesNullAvatarURL() {
let mapper = UserProfileMapper()
let jsonWithNull: [String: Any] = [
"name": "Alice",
"email": "alice@example.com",
"avatar_url": NSNull()
]
// Force-unwrap on NSNull crashes
let profile = mapper.map(from: jsonWithNull)
XCTAssertNil(profile.avatarURL)
}
}Correct (guard let with defaults handles missing and null values safely):
import Foundation
struct UserProfile {
let name: String
let email: String
let avatarURL: URL?
}
class UserProfileMapper {
func map(from json: [String: Any]) -> UserProfile? {
// Guard required fields — return nil instead of crashing
guard let name = json["name"] as? String,
let email = json["email"] as? String else {
return nil
}
let avatarURL = (json["avatar_url"] as? String)
.flatMap { URL(string: $0) } // nil-safe chain
return UserProfile(
name: name,
email: email,
avatarURL: avatarURL
)
}
}Array Index Access Without Bounds Check Crashes
Direct array subscript access (array[index]) triggers a fatal error when the index is out of bounds. Unlike dictionary subscript, array subscript does not return an optional. This crashes deterministically but often passes code review because the happy path works.
Incorrect (direct subscript access crashes on out-of-bounds index):
import Foundation
class PageController {
private let pages: [String]
private var currentIndex: Int = 0
init(pages: [String]) {
self.pages = pages
}
func navigateTo(index: Int) -> String {
currentIndex = index
return pages[index] // fatal error if index >= pages.count
}
func currentPage() -> String {
pages[currentIndex]
}
func nextPage() -> String {
currentIndex += 1
return pages[currentIndex] // crashes on last page
}
}Proof Test (exposes the crash with an out-of-bounds index):
import XCTest
final class PageControllerBoundsTests: XCTestCase {
func testNavigateToInvalidIndexDoesNotCrash() {
let controller = PageController(pages: ["Home", "Settings", "Profile"])
// Index beyond array count — crashes without bounds check
let result = controller.navigateTo(index: 5)
XCTAssertNil(result, "Out-of-bounds index should return nil, not crash")
}
func testNextPageBeyondEndDoesNotCrash() {
let controller = PageController(pages: ["Home", "Settings"])
_ = controller.navigateTo(index: 1)
let result = controller.nextPage() // index 2 — out of bounds
XCTAssertNil(result, "Navigating past last page should return nil")
}
}Correct (safe subscript returns nil for out-of-bounds access):
import Foundation
extension Array {
subscript(safe index: Index) -> Element? {
indices.contains(index) ? self[index] : nil
}
}
class PageController {
private let pages: [String]
private var currentIndex: Int = 0
init(pages: [String]) {
self.pages = pages
}
func navigateTo(index: Int) -> String? {
guard let page = pages[safe: index] else { return nil }
currentIndex = index
return page // safe — returns nil instead of crashing
}
func currentPage() -> String? {
pages[safe: currentIndex]
}
func nextPage() -> String? {
let next = currentIndex + 1
guard let page = pages[safe: next] else { return nil }
currentIndex = next
return page
}
}KVO Observer Not Removed Before Deallocation Crashes
If an object registered as a KVO observer is deallocated without removing the observation, the next KVO notification sends a message to a freed pointer. This produces EXC_BAD_ACCESS with no useful stack trace, making it one of the hardest crashes to debug.
Incorrect (observer deallocates without removing KVO registration):
import Foundation
class ProgressTracker: NSObject {
private let task: URLSessionTask
var onProgress: ((Double) -> Void)?
init(task: URLSessionTask) {
self.task = task
super.init()
// Registers KVO observation — must be removed before dealloc
task.addObserver(self, forKeyPath: "countOfBytesReceived",
options: .new, context: nil)
}
override func observeValue(
forKeyPath keyPath: String?,
of object: Any?,
change: [NSKeyValueChangeKey: Any]?,
context: UnsafeMutableRawPointer?
) {
let received = task.countOfBytesReceived
let total = task.countOfBytesExpectedToReceive
guard total > 0 else { return }
onProgress?(Double(received) / Double(total))
}
// Missing deinit — observer never removed
}Proof Test (exposes EXC_BAD_ACCESS after observer is deallocated):
import XCTest
final class ProgressTrackerKVOTests: XCTestCase {
func testTrackerDeallocDoesNotCrashOnNextNotification() {
let session = URLSession.shared
let request = URLRequest(url: URL(string: "https://example.com")!)
let task = session.dataTask(with: request)
var tracker: ProgressTracker? = ProgressTracker(task: task)
tracker?.onProgress = { progress in
print("Progress: \(progress)")
}
// Deallocate the observer — KVO registration still active
tracker = nil
// Next KVO notification dereferences the freed tracker — EXC_BAD_ACCESS
// Triggering any property change on task would crash
XCTAssertNil(tracker, "Tracker should be nil without crashing")
}
}Correct (removes KVO observer in deinit, preventing dangling pointer):
import Foundation
class ProgressTracker: NSObject {
private let task: URLSessionTask
var onProgress: ((Double) -> Void)?
init(task: URLSessionTask) {
self.task = task
super.init()
task.addObserver(self, forKeyPath: "countOfBytesReceived",
options: .new, context: nil)
}
override func observeValue(
forKeyPath keyPath: String?,
of object: Any?,
change: [NSKeyValueChangeKey: Any]?,
context: UnsafeMutableRawPointer?
) {
let received = task.countOfBytesReceived
let total = task.countOfBytesExpectedToReceive
guard total > 0 else { return }
onProgress?(Double(received) / Double(total))
}
deinit {
// Remove observer before deallocation — prevents EXC_BAD_ACCESS
task.removeObserver(self, forKeyPath: "countOfBytesReceived")
}
}Swift/ObjC Bridge Type Mismatch Crashes at Runtime
Objective-C collections are untyped -- an NSArray may contain NSString, NSNumber, and NSNull in the same array. Bridging to a typed Swift Array<String> with as! crashes at runtime if any element does not match. The compiler cannot catch this because the ObjC type system has no generics.
Incorrect (force-casts untyped NSArray to typed Swift array, crashing on mismatch):
import Foundation
class AnalyticsEventParser {
func parseEventNames(from objcArray: NSArray) -> [String] {
// ObjC array may contain NSNumber or NSNull — force-cast crashes
let names = objcArray as! [String]
return names
}
func parseEventValues(from objcDict: NSDictionary) -> [String: Int] {
// Mixed value types in NSDictionary crash on bridge
let values = objcDict as! [String: Int]
return values
}
}Proof Test (exposes crash when NSArray contains mixed types):
import XCTest
final class AnalyticsEventParserBridgeTests: XCTestCase {
func testParseEventNamesHandlesMixedTypes() {
let parser = AnalyticsEventParser()
// ObjC SDK returns mixed-type array
let mixedArray: NSArray = [
"screen_view",
NSNumber(value: 42),
NSNull(),
"button_tap"
]
// Force-cast to [String] crashes on NSNumber element
let names = parser.parseEventNames(from: mixedArray)
XCTAssertEqual(names, ["screen_view", "button_tap"])
}
func testParseEventValuesHandlesMixedValueTypes() {
let parser = AnalyticsEventParser()
let mixedDict: NSDictionary = [
"clicks": NSNumber(value: 5),
"label": "home", // String instead of Int
"views": NSNumber(value: 100)
]
let values = parser.parseEventValues(from: mixedDict)
XCTAssertEqual(values["clicks"], 5)
}
}Correct (uses compactMap with conditional casts to safely filter matching types):
import Foundation
class AnalyticsEventParser {
func parseEventNames(from objcArray: NSArray) -> [String] {
// Conditional cast filters non-String elements safely
return objcArray.compactMap { $0 as? String }
}
func parseEventValues(from objcDict: NSDictionary) -> [String: Int] {
var result: [String: Int] = [:]
for (key, value) in objcDict {
// Only include entries where both key and value match expected types
if let key = key as? String, let intValue = value as? Int {
result[key] = intValue
}
}
return result
}
}Missing dynamic Keyword Breaks Method Swizzling
Swift can optimize method dispatch to bypass the Objective-C runtime. Without the dynamic keyword, the compiler may use static or vtable dispatch, which means method swizzling targets the original implementation but the optimized call path never goes through it. The swizzled method is silently never called.
Incorrect (swizzles method without dynamic keyword, swizzle has no effect):
import UIKit
class AnalyticsTracker: NSObject {
static let shared = AnalyticsTracker()
private(set) var trackedScreens: [String] = []
static func startTracking() {
let originalSelector = #selector(
UIViewController.viewDidAppear(_:))
let swizzledSelector = #selector(
UIViewController.tracked_viewDidAppear(_:))
guard let originalMethod = class_getInstanceMethod(
UIViewController.self, originalSelector),
let swizzledMethod = class_getInstanceMethod(
UIViewController.self, swizzledSelector)
else { return }
method_exchangeImplementations(originalMethod, swizzledMethod)
}
func track(screen: String) {
trackedScreens.append(screen)
}
}
extension UIViewController {
// Missing @objc dynamic — Swift may use static dispatch
func tracked_viewDidAppear(_ animated: Bool) {
tracked_viewDidAppear(animated) // calls original via swizzle
let screenName = String(describing: type(of: self))
AnalyticsTracker.shared.track(screen: screenName)
}
}Proof Test (verifies swizzled method is actually invoked):
import XCTest
import UIKit
final class AnalyticsTrackerSwizzleTests: XCTestCase {
override class func setUp() {
super.setUp()
AnalyticsTracker.startTracking()
}
func testViewDidAppearIsTracked() {
let vc = UIViewController()
let window = UIWindow(frame: .zero)
window.rootViewController = vc
window.makeKeyAndVisible()
vc.beginAppearanceTransition(true, animated: false)
vc.endAppearanceTransition()
// Without dynamic, the swizzled method is never called
XCTAssertFalse(
AnalyticsTracker.shared.trackedScreens.isEmpty,
"Swizzled viewDidAppear was never called — missing dynamic keyword"
)
}
}Correct (@objc dynamic forces Objective-C dispatch, making swizzle effective):
import UIKit
class AnalyticsTracker: NSObject {
static let shared = AnalyticsTracker()
private(set) var trackedScreens: [String] = []
static func startTracking() {
let originalSelector = #selector(
UIViewController.viewDidAppear(_:))
let swizzledSelector = #selector(
UIViewController.tracked_viewDidAppear(_:))
guard let originalMethod = class_getInstanceMethod(
UIViewController.self, originalSelector),
let swizzledMethod = class_getInstanceMethod(
UIViewController.self, swizzledSelector)
else { return }
method_exchangeImplementations(originalMethod, swizzledMethod)
}
func track(screen: String) {
trackedScreens.append(screen)
}
}
extension UIViewController {
@objc dynamic func tracked_viewDidAppear(_ animated: Bool) {
tracked_viewDidAppear(animated) // calls original via swizzle
let screenName = String(describing: type(of: self))
AnalyticsTracker.shared.track(screen: screenName)
}
}Related skills
FAQ
What does ios-chaos-monkey do?
ios-chaos-monkey: A skill for development. This provides functionality for development workflows.
When should I use ios-chaos-monkey?
When you need to use ios-chaos-monkey for development tasks, or when ios-chaos-monkey: a skill for development. this provides functionality for development workflows.
What are the main capabilities?
ios-chaos-monkey.