Lab 2.2 — Debug a buggy app
Duration: ~75 minutes Difficulty: Intermediate Prereqs: Chapter 2.5 (Debugging), a working Xcode
Goal
Find and fix three deliberate bugs in a starter app using only Xcode’s debugging tools — LLDB, breakpoints, View Debugger, and Memory Graph Debugger. No reading source code to “spot the bug” — diagnose like you would a production issue, starting from the symptom.
What you’ll debug
A SwiftUI app called BuggyFeed with:
- A list of “posts” (mocked)
- A detail view when you tap a post
- A “Like” button on each post
The three bugs
- Layout bug — On some posts, the title is invisible (cut off / zero-height).
- Memory leak — Every push of the detail view leaks the view model. The Memory Graph Debugger will show duplicates accumulating.
- Threading bug — Tapping “Like” rapidly sometimes crashes with a
Thread Sanitizerwarning, or shows the wrong like count.
Setup — create the starter app
Create a new SwiftUI app called BuggyFeed. Replace the contents of the auto-generated files with:
BuggyFeedApp.swift
import SwiftUI
@main
struct BuggyFeedApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
ContentView.swift
import SwiftUI
struct Post: Identifiable {
let id = UUID()
var title: String
var body: String
var likes: Int = 0
}
@MainActor
final class FeedViewModel: ObservableObject {
@Published var posts: [Post] = [
Post(title: "Hello world", body: "This is the first post."),
Post(title: "", body: "This post has an empty title."),
Post(title: "Another day", body: "Coffee and code."),
Post(title: "SwiftUI tips", body: "Use @StateObject for owned models."),
]
}
struct ContentView: View {
@StateObject private var feed = FeedViewModel()
var body: some View {
NavigationStack {
List($feed.posts) { $post in
NavigationLink {
PostDetailView(post: $post)
} label: {
VStack(alignment: .leading) {
Text(post.title) // BUG 1 lives here-ish
.font(.headline)
Text(post.body)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Buggy Feed")
}
}
}
PostDetailView.swift
import SwiftUI
@MainActor
final class PostDetailViewModel: ObservableObject {
@Published var localLikes: Int
private var timer: Timer?
init(initialLikes: Int) {
self.localLikes = initialLikes
// BUG 2: timer captures self strongly and is never invalidated
self.timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
// simulating "live updates"
Task { @MainActor in
_ = self // pretend we use self
}
}
}
func likeMore() {
// BUG 3: writing from multiple Tasks without isolation
Task.detached {
let new = await self.localLikes + 1
await MainActor.run { self.localLikes = new }
}
}
}
struct PostDetailView: View {
@Binding var post: Post
@StateObject private var vm: PostDetailViewModel
init(post: Binding<Post>) {
self._post = post
self._vm = StateObject(wrappedValue: PostDetailViewModel(initialLikes: post.wrappedValue.likes))
}
var body: some View {
VStack(spacing: 16) {
Text(post.title)
.font(.largeTitle)
Text(post.body)
Text("Likes: \(vm.localLikes)")
Button("Like!") {
vm.likeMore()
post.likes = vm.localLikes
}
.buttonStyle(.borderedProminent)
Spacer()
}
.padding()
}
}
Run the app. The bugs will all be reproducible.
Investigation 1 — The invisible title
Symptom
The second post in the list looks “blank” — only the body is showing. Why?
Diagnosis with View Debugger
- Run the app
- Debug menu → View Debugging → Capture View Hierarchy
- The 3D hierarchy view appears
- In the left sidebar, expand the list cells; find the second cell
- Inside, you’ll find a
Textview with frame(0, 0, x, 0)— zero height - Click the
Text→ Object Inspector (⌥⌘4) → confirmtext = ""
Fix
The bug: the data has an empty title; the view doesn’t handle that gracefully. Fix in ContentView.swift:
Text(post.title.isEmpty ? "Untitled" : post.title)
.font(.headline)
Rebuild and confirm the second post now reads “Untitled.”
✅ Bug 1 fixed. Diagnosed entirely from the rendered hierarchy, not the source.
Investigation 2 — The memory leak
Symptom
You’re not sure there’s a leak; you just heard the lab said there’s one. How do you confirm?
Diagnosis with Memory Graph Debugger
- Run the app
- Navigate into a post; navigate back
- Repeat 5 times (push detail, pop, push different post, pop, …)
- While still running, click the Debug Memory Graph button in the debug bar (icon with three connected circles)
- Xcode pauses and displays the live object graph
- In the left sidebar, search for
PostDetailViewModel - You’ll see 5 instances — but you’ve only navigated into one detail view at a time! There should be 0 (or at most 1).
- Click the most recent instance → the graph shows it’s retained by a
Timer - The timer’s
blockcapturesselfstrongly → reference cycle
Fix
In PostDetailView.swift, two changes:
init(initialLikes: Int) {
self.localLikes = initialLikes
self.timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
Task { @MainActor in
guard let self else { return }
_ = self
}
}
}
deinit {
timer?.invalidate()
}
Rebuild. Navigate 5 times again. Memory Graph → search PostDetailViewModel → should show 0 (or 1 if you’re currently in a detail view).
✅ Bug 2 fixed. Diagnosed entirely from the retain graph.
Investigation 3 — The threading bug
Symptom
Tap “Like” rapidly — sometimes the count is wrong, sometimes you get a Thread Sanitizer warning, sometimes the app crashes.
Diagnosis with Thread Sanitizer
- Edit scheme (⌘<) → Run → Diagnostics → tick Thread Sanitizer
- Rebuild (⌘B) and run (⌘R)
- Navigate into a post
- Tap “Like” 10 times rapidly
- Watch the console: you should see a Thread Sanitizer error like:
WARNING: ThreadSanitizer: data race
Read of size 8 at 0x... by thread T1
Previous write at 0x... by main thread
Location: BuggyFeed/PostDetailView.swift:23
- Click the line in the trace; Xcode jumps to
likeMore()
The bug: Task.detached reads self.localLikes from a background thread, but localLikes is @Published on a @MainActor class. The read happens off the main actor — undefined behavior.
Fix
Rewrite likeMore() cleanly:
func likeMore() {
localLikes += 1
}
The original “increment via a detached task” pattern was contrived — the real fix is to do mutation on the main actor where the property lives.
Rebuild with Thread Sanitizer still on. Tap “Like” 10 times rapidly. No warnings. Count is correct every time.
✅ Bug 3 fixed. Diagnosed by enabling Thread Sanitizer in the scheme.
Stretch — add a conditional breakpoint to assert no future regressions
In PostDetailView.likeMore(), set a breakpoint. Right-click → Edit Breakpoint…:
- Condition:
!Thread.isMainThread - Action: Log Message → “❌ likeMore called off main thread”
- Action: Debugger Command →
expression assert(false) - Tick “Automatically continue after evaluating actions”
This breakpoint never fires in normal use, but if someone refactors likeMore to call from a background thread, the breakpoint will crash debug builds immediately at the point of the bug. Ship-blockers caught at debug time.
Validation checklist
- All three bugs reproduce in the unfixed starter
- You used View Debugger to find Bug 1 (not source code reading)
- You used Memory Graph Debugger to find Bug 2
- You used Thread Sanitizer to find Bug 3
- All three fixes are applied; app behaves correctly
- Thread Sanitizer remains enabled with no warnings during rapid like-tapping
- Optional: conditional breakpoint added as regression guard
What you’ve internalized
- The three Xcode debugging tools every iOS engineer uses weekly: View Debugger, Memory Graph Debugger, Thread Sanitizer
- The pattern of forming a hypothesis then picking the right tool, instead of reading source
- The hidden value of conditional breakpoints as runtime assertions
- Why
@MainActormatters and why off-actor reads are not just “warnings” but real bugs