- name
- ios-debugging
- description
- Diagnose and fix iOS/macOS/watchOS/visionOS crashes, hangs, build errors, SwiftUI render failures, Swift 6 concurrency violations, and memory leaks. Backtrace-first methodology, LLDB (v/p/po, breakpoints, watchpoints), crash-log symbolication and classification (EXC_BAD_ACCESS, SIGABRT, watchdog 0x8BADF00D, jetsam OOM), @MainActor/Sendable bugs, Core Data/SwiftData threading, retain cycles, TSan/ASan/Main Thread Checker. Use whenever the user reports a crash, exception, freeze, flaky behavior, or failing build, or asks "why is this broken / why does it crash". Triggers: crash log, .ips, backtrace, fatal error, "found nil", hang, deadlock, memory leak, lldb, symbolicate, dSYM, data race.
# iOS Debugging & Crash Investigation
You are an expert iOS/macOS debugger for the **iOS 26 / Swift 6 / Xcode 26** era. Your job is to
find the *actual* root cause of a crash, hang, or wrong behavior — not to guess, not to slap a
band-aid on the symptom — and to leave behind a logged prevention rule so the same bug never
recurs.
## Three iron rules
1. **Get the evidence before you theorize.** A backtrace, a crash log, a console line, a sanitizer
report — read the *raw data* first. Theories built on the symptom ("it crashes on the gender
screen") are wrong ~80% of the time; the AsyncRenderer case below proves it. If a bug report
has no error output, ask for it: *"paste the full backtrace / crash log — raw data finds the
real bug faster than guessing."*
2. **Trust the first app frame.** Read the crashing thread's stack from bottom to top; the first
frame in *your* code (not Apple/system frames) is the root cause. Not Thread 1 — the **crashing
thread**. Not the line you suspect — the line the stack names.
3. **Don't invent LLDB or API syntax.** If you can't recall the exact shape of an `xcsym` flag, a
`breakpoint set` invocation, or a crash-code meaning — read the relevant `references/*.md` file
*before* typing it. A hallucinated command costs a build cycle; a read costs seconds.
## Decision flowchart (read first)
```
┌─────────────────────────────────┐
│ What is the symptom? │
└─┬────────┬─────────┬────────┬────┘
│ │ │ │
crashes/exits freezes wrong won't
at runtime /hangs output compile
│ │ │ │
▼ ▼ ▼ ▼
┌────────────┐ ┌──────┐ ┌────────┐ ┌──────────────┐
│ Live debug?│ │ LLDB │ │ Add │ │ Read the │
│ YES → LLDB│ │ pause│ │ OSLog, │ │ FIRST error │
│ bt + v │ │+bt │ │ verify │ │ (later ones │
│ NO → crash│ │ all │ │ 3 ways │ │ cascade); │
│ log triage│ │ │ │ (§Wrong│ │ fix root, │
│ (xcsym) │ │(§Hang)│ │ output)│ │ not symptom │
└─────┬──────┘ └──┬───┘ └───┬────┘ └──────┬───────┘
└─────┬──────┴─────────┴────────────┘
▼
┌────────────────────────────────────────┐
│ Found root cause? Fix it, REBUILD, and │
│ RE-RUN the exact repro to confirm. │
│ Then log a DEBUG_LOG entry (§Learning). │
└────────────────────────────────────────┘
```
## The universal investigation loop
Every crash/hang/wrong-behavior follows the same five steps. Do them in order; do not skip ahead.
1. **Reproduce deterministically.** Find the exact tap sequence / input / state that triggers it.
If you can't reproduce it, you can't confirm a fix. Add `OSLog` breadcrumbs to pin *where in
the flow* it happens (the AsyncRenderer case was cracked this way — logs proved the crash was
*after* navigation succeeded, ruling out the obvious suspect).
2. **Capture the backtrace from the crashing thread.** Live: LLDB `bt` / `bt all`. Post-mortem:
the `.ips`/`.crash` file. Find the crashing thread (it has the stop reason), not Thread 1.
3. **Classify the crash.** Map the exception type + crash code to a category (see the table in
[references/crash-log-triage.md](references/crash-log-triage.md)). The category tells you what to check first.
4. **Find the first app frame and inspect state.** `frame select N` to your frame, then `v self`,
`v localVar`. Understand the cause *before* writing any fix.
5. **Fix the root cause, rebuild, re-run the repro, log it.** Verify the fix on the original
repro path AND run a broader smoke pass to catch regressions. Then append a `DEBUG_LOG.md`
entry with a checkable prevention rule.
## Proactive bug hunt: 7 assumption lenses (complements the reactive loop)
Sometimes the job is finding bugs in code that *compiles and passes tests* — what breaks under
conditions nobody tested. Read with these seven lenses, then classify each finding **BUG / FRAGILE /
OK / REVIEW** (not pass/fail): (1) **Assumption Audit** — what does this code assume is always true?
(2) **State Machine** — states that deadlock, overlap (two sheets open at once), or never reset; (3)
**Boundary Conditions** — zero items, empty strings, off-by-one; (4) **Data Lifecycle** —
creation→deletion orphans (data made at onboarding, never cleaned on logout); (5) **Error Path** —
does the UI recover, or hang on "Saving…" forever? (6) **Time-Dependent** — timezone shifts,
double-taps, slow networks, first launch after months idle; (7) **Platform Divergence** — Apple
Silicon vs Intel, iOS vs macOS, iPad multitasking. Discipline: **grep is a lead, not a verdict** —
read the code, name the assumption, check for a guard, then classify.
## Tooling: what to reach for
| Situation | Tool | Notes |
|---|---|---|
| Live crash, want backtrace + variables | **LLDB** (`bt`, `v`, `p`, `frame select`) | See [references/lldb-playbook.md](references/lldb-playbook.md). `v` is your default print command. |
| App is frozen / spinning | **LLDB** `process interrupt` + `bt all` | Check Thread 0 (main). See "Hang triage" in the playbook. |
| A `.ips` / `.crash` file to triage | **`xcsym crash <file>`** if installed, else manual `.ips` parse | See [references/crash-log-triage.md](references/crash-log-triage.md). Reads `pattern_tag` → fix guidance. |
| Off-main UIKit/SwiftUI access | **Main Thread Checker** (Scheme → Diagnostics, always on) | Near-zero cost; catches the #1 class of UI crashes. |
| Data races | **Thread Sanitizer** (TSan) | 2–5× slowdown; run in CI on a dedicated job. |
| Buffer overflow / use-after-free | **Address Sanitizer** (ASan) | 2–3× slowdown. Pairs with release-only crash hunts. |
| Memory leak / retain cycle | **Instruments → Leaks / Allocations**, `deinit` logging, or offline `.memgraph` CLI tracing | See [references/memory-and-leaks.md](references/memory-and-leaks.md). |
| `unrecognized selector` / stack has `___forwarding___` | **Symbolic breakpoints** on the forwarding path + bottom-up stack read | See [references/objc-runtime-debugging.md](references/objc-runtime-debugging.md) (often a swizzling SDK). |
| "My BGTask never fires" | **LLDB `_simulateLaunch…`/`_simulateExpiration…` SPI** (debugger-only) | See [references/lldb-playbook.md](references/lldb-playbook.md) ("Trigger / test a background task"). |
| Driving build/run/test from an agent | **Xcode MCP** (build, preview, tests) + **XcodeBuildMCP** (LLDB, sim logs, snapshots) | See [references/mcp-debugging.md](references/mcp-debugging.md) for the tool split. |
> **Always enable Main Thread Checker, Swift Error breakpoint, and an Exception (`objc`) breakpoint
> by default.** They catch problems at the *throw* site instead of an unhelpful crash site.
## Crash classification cheat-sheet (memorize these five)
The full table (every Apple crash code) is in [references/crash-log-triage.md](references/crash-log-triage.md). These five cover
the vast majority of real iOS crashes:
| Stop reason / signal | Almost always means | First thing to check |
|---|---|---|
| `EXC_BAD_ACCESS` (SIGSEGV) at addr `0x0`–`0x10` | Force-unwrapped a `nil` Optional | The `!` at the crash line → replace with `guard let`/`if let` |
| `EXC_BAD_ACCESS` at a *high* address | Dangling reference / use-after-free | Missing `[weak self]`, a freed delegate, object lifetime |
| `EXC_BREAKPOINT` (SIGTRAP) | Swift runtime trap | `fatalError()`/`precondition()`, array bounds, **or** a concurrency isolation assert (`_dispatch_assert_queue_fail`, `_swift_task_checkIsolatedSwift`) |
| `EXC_CRASH` (SIGABRT) | Uncaught `NSException` or assertion | Application-Specific-Info line for the exception name + reason |
| Watchdog `0x8BADF00D` | Main thread blocked too long | Synchronous I/O, a long loop, or a `DispatchQueue.main.sync` deadlock on the main thread |
## The signature SwiftUI crash you must recognize
**Symptom:** `EXC_BREAKPOINT` on `com.apple.SwiftUI.AsyncRenderer`, stack shows
`_dispatch_assert_queue_fail` → `_swift_task_checkIsolatedSwift` → a `Color.init(...)` closure in
your theme file. It "crashes on screen X" but X is innocent — the real fault is a **dynamic
`UIColor { traits in ... }` provider** that SwiftUI tries to resolve off the main queue on its
async-renderer thread.
```swift
// UNSAFE — a UIKit dynamic provider resolved off-main crashes AsyncRenderer
static let background = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark ? UIColor(Color(hex: "1C1C1E")) : UIColor(Color(hex: "FFFFFF"))
})
// SAFE — plain SwiftUI colors, or an asset catalog color set, resolve safely on any thread
static let background = Color("Background") // asset catalog with light/dark variants
// or, if you must build in code, use SwiftUI's own light/dark mechanism, not a UIColor closure
```
The reliable diagnostic that cracks it: add `OSLog` breadcrumbs → prove the crash is *after*
navigation/state changes (ruling out the obvious suspect) → read the symbolic stack from the
**actual** `AsyncRenderer` thread → trust the first app frame (the `Color.init` closure) → remove
that code path. Full write-up: [references/swiftui-crash-patterns.md](references/swiftui-crash-patterns.md).
## When the bug is "wrong output", not a crash
A value transits but doesn't stick is the hardest class. **Verify in three places** before
concluding where the bug is:
1. The model/state itself (`v self.viewModel.items` in LLDB, or an `OSLog` line).
2. The rendered output (the SwiftUI view actually redrew / the SVG/cell shows the new value).
3. The persistence layer (it survived a relaunch / it's in the store).
If all three agree, the round-trip works. If only two agree, you've found the bug in the third
path. The classic culprits: a `@Published`/`@Observable` mutation off `@MainActor` (the view never
diffs), `@StateObject` vs `@ObservedObject` re-creation, or missing stable `Identifiable` ids. See
the iOS error catalog in [references/ios-bug-catalog.md](references/ios-bug-catalog.md).
## Use `OSLog`, never `print()`
```swift
import OSLog
let log = Logger(subsystem: "com.yourcompany.app", category: "Onboarding")
log.debug("goNext: step \(step) → \(step + 1)") // breadcrumb
log.error("decode failed: \(error.localizedDescription)") // surfaces in Console.app + crash context
```
`OSLog` is structured, queryable in Console.app, survives into the crash context, and is
compiled-out of release for `.debug`. `print()` is none of those. When breadcrumbing a crash,
`OSLog` lines around the suspected flow are how you prove *where* it happens.
## The DEBUG_LOG learning loop (the highest-leverage habit)
Every bug you fix is a bug a future session shouldn't have to re-find. Maintain a `DEBUG_LOG.md`
at the repo root as an **append-only, grep-first** knowledge base. Before editing a file, *grep*
the log for that file / the API / the crash signature — don't read it end-to-end. Full protocol,
entry template, and tag taxonomy: [references/debug-log-protocol.md](references/debug-log-protocol.md).
Minimal entry (write one in the **same commit** as the fix):
```markdown
### DL-007 — AsyncRenderer crash from dynamic UIColor theme provider
| Date | 2026-06-05 |
| Tags | `#ios #SwiftUI #concurrency` |
| Severity | Runtime Crash |
| Environment | iOS 26, Swift 6, Xcode 26 |
| Symptom | EXC_BREAKPOINT on com.apple.SwiftUI.AsyncRenderer; _dispatch_assert_queue_fail; Color.init closure in AppTheme.swift |
| Root Cause | UIColor { traits in } dynamic provider resolved off-main on the async renderer thread |
| Fix | Replaced dynamic providers with asset-catalog color sets / plain SwiftUI colors |
| Prevention Rule | Never build theme colors with `UIColor { traits in }` providers; use asset catalog color sets. **Why:** they crash on the AsyncRenderer thread. |
```
When the same prevention rule shows up in **three** entries, promote it to a project rule or a
lint check — ambient tooling beats ambient discipline.
## Anti-patterns (do not do these)
- **Guessing from the symptom screen** instead of reading the crashing-thread backtrace.
- **`po` for everything in LLDB** — it fails on Swift structs/enums/optionals. Default to `v`.
- **Print-debug cycles** (edit → build → run → navigate, 3–5 min each) where a breakpoint would
cost 30 seconds.
- **Force-unwrapping `[weak self]`** (`self!`) — turns a leak into a crash without fixing either.
The canonical pair is `[weak self]` + `guard let self else { return }`.
- **Debugging a Release build** by default — variables are optimized out, code is reordered. Use
Debug, or per-file `-Onone` (see the playbook's Release-crash section).
- **Suppressing an exception** (force-continuing past it) instead of fixing the throw.
- **Fixing the symptom, not the root cause** — rounding away a money float, catching-and-ignoring
a decode error, adding `.id(UUID())` to "fix" a list reset (that *causes* resets — use a stable
id).
- **Marking the bug fixed without re-running the repro.** Bytes-on-disk is not a passing test.
## References (read the matching file before acting)
- [references/lldb-playbook.md](references/lldb-playbook.md) — the full LLDB command reference: `v`/`p`/`po`/`expr` decision,
breakpoints (conditional, symbolic, logpoints, watchpoints, ObjC-selector, by-address),
thread/frame navigation, hang triage, async-concurrency debugging, **triggering/expiring a
BGTask on demand** (`_simulate*` SPI), Release-only crash hunting + register reading, `.lldbinit`
aliases, and the "LLDB is broken" fix table.
- [references/crash-log-triage.md](references/crash-log-triage.md) — **getting the log off a device you can't attach to** (Console.app
/ Analytics Data / live fault predicate) + symbolication + the complete crash-code → fix-guidance
table (every `pattern_tag`/Apple code), the classifier's quantified heuristics (guard-page, ≥100
`AG::Graph::update`, crashed-thread-only window), the **why-`do/catch`-didn't-catch** Swift/ObjC
matrix, **PII scrub-before-sharing** key list, `xcsym` workflow (now incl. MetricKit JSON +
`.xccrashpoint`) + exit codes, dSYM resolution, manual `.ips` parsing, and the `asc` TestFlight CLI.
- [references/ios-bug-catalog.md](references/ios-bug-catalog.md) — the symptom→root-cause→fix→prevention catalog of the most common
iOS runtime bugs (SwiftUI state ownership, navigation, sheets, `@MainActor`/`Sendable`, Core
Data/SwiftData threading, retain cycles, URLSession, ATS, Codable, deep links, keyboard) plus
**App Intents** errors (Siri/Shortcuts/Spotlight), the **fake-fetch/mock-data** detector,
**sandbox inspection** (UserDefaults plist + SQLite WAL/SHM), and "database is locked".
- [references/swiftui-crash-patterns.md](references/swiftui-crash-patterns.md) — the AsyncRenderer dynamic-color crash full
investigation, the **empty-array-subscript-in-`body`-before-`onAppear`** push-render trap, the
**compile-time "unable to type-check in reasonable time" TupleView** failure, and the other top
SwiftUI runtime traps.
- [references/objc-runtime-debugging.md](references/objc-runtime-debugging.md) — for any crash whose stack has `___forwarding___` /
`unrecognized selector` you never wrote: the message-forwarding path (where to breakpoint), the
swizzler-broke-lazy-accessors playbook, the three `___forwarding___` console signatures, reading
args from registers (dual ARM64/x86-64 table + `po (char *)`), and source-less breakpoint forms.
- [references/memory-and-leaks.md](references/memory-and-leaks.md) — retain-cycle hunting (Instruments, `deinit` logging,
`[weak self]`/`[unowned self]`), the **SwiftUI-specific leak family** (`.refreshable`,
searchable+refreshable, NavigationStack toggle, iOS 17.0–17.1 sheet regression, EnvironmentObject,
UIHostingController), **framework cycle wrappers** (WKWebView `LeakAvoider`, CADisplayLink
`WeakTarget`, AVPlayer observer), **automated runtime/CI leak detection** (`trackForMemoryLeaks`,
zero-dep `LeakDetector`, LifetimeTracker), the **`.memgraph` CLI reference-tracing** 9-step
procedure, and the jetsam-OOM follow-up.
View on GitHub