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

  1. Layout bug — On some posts, the title is invisible (cut off / zero-height).
  2. Memory leak — Every push of the detail view leaks the view model. The Memory Graph Debugger will show duplicates accumulating.
  3. Threading bug — Tapping “Like” rapidly sometimes crashes with a Thread Sanitizer warning, 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

  1. Run the app
  2. Debug menu → View Debugging → Capture View Hierarchy
  3. The 3D hierarchy view appears
  4. In the left sidebar, expand the list cells; find the second cell
  5. Inside, you’ll find a Text view with frame (0, 0, x, 0) — zero height
  6. Click the Text → Object Inspector (⌥⌘4) → confirm text = ""

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

  1. Run the app
  2. Navigate into a post; navigate back
  3. Repeat 5 times (push detail, pop, push different post, pop, …)
  4. While still running, click the Debug Memory Graph button in the debug bar (icon with three connected circles)
  5. Xcode pauses and displays the live object graph
  6. In the left sidebar, search for PostDetailViewModel
  7. 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).
  8. Click the most recent instance → the graph shows it’s retained by a Timer
  9. The timer’s block captures self strongly → 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

  1. Edit scheme (⌘<) → Run → Diagnostics → tick Thread Sanitizer
  2. Rebuild (⌘B) and run (⌘R)
  3. Navigate into a post
  4. Tap “Like” 10 times rapidly
  5. 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
  1. 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 @MainActor matters and why off-actor reads are not just “warnings” but real bugs

Next: Lab 2.3 — Instruments profiling